Compare commits

..

34 Commits

Author SHA1 Message Date
gemini-cli-robot df72e3af3d chore(release): v0.24.0-preview.2 2026-01-13 23:34:59 +00:00
gemini-cli-robot 334b813d81 fix(patch): cherry-pick 356f76e to release/v0.24.0-preview.1-pr-16252 to patch version v0.24.0-preview.1 and create version 0.24.0-preview.2 (#16552)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2026-01-13 22:40:08 +00:00
gemini-cli-robot 314f67a326 chore(release): v0.24.0-preview.1 2026-01-13 05:44:29 +00:00
gemini-cli-robot 0f0d1d8fc0 fix(patch): cherry-pick b54e688 to release/v0.24.0-preview.0-pr-16284 to patch version v0.24.0-preview.0 and create version 0.24.0-preview.1 (#16466)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-01-13 11:43:02 +08:00
gemini-cli-robot cb15a238fe chore(release): v0.24.0-preview.0 2026-01-07 01:43:58 +00:00
N. Taylor Mullen 59a18e710d Agent Skills: Initial Documentation & Tutorial (#15869) 2026-01-07 00:59:56 +00:00
Krushna Korade 5f027cb63a fix: hide broken skills object from settings dialog (#15766) 2026-01-07 00:55:13 +00:00
Christian Gunderman 4086abf375 Fix test. (#16011) 2026-01-07 00:25:16 +00:00
Jerop Kipruto 8f5bf33eac ci(github-actions): triage all new issues automatically (#16018) 2026-01-07 00:15:00 +00:00
Jerop Kipruto 7eeb7bd74c fix: limit scheduled issue triage queries to prevent argument list too long error (#16021) 2026-01-07 00:03:23 +00:00
Adam Weidman 96b9be3ec4 feat(agents): add support for remote agents (#16013) 2026-01-06 23:45:05 +00:00
joshualitt 1e31427da8 Remove trailing whitespace in yaml. (#16036) 2026-01-06 15:41:58 -08:00
Bryan Morgan 7feb2f8f42 Add 'reopened' type to issue labeling workflow 2026-01-06 18:17:12 -05:00
Bryan Morgan 2122604b32 Refactor parent issue check to use URLs 2026-01-06 18:00:39 -05:00
Bryan Morgan d4b4aede2f Add debugging logs for issue parent checks
Added debugging logs to inspect issue object and parent information.
2026-01-06 17:56:39 -05:00
Bryan Morgan 4b5c044272 Fix label-backlog-child-issues workflow logic 2026-01-06 17:48:08 -05:00
Shreya Keshive 2fe45834dd feat(admin): Introduce remote admin settings & implement secureModeEnabled/mcpEnabled (#15935) 2026-01-06 21:38:07 +00:00
joshualitt 56092bd782 feat(hooks): Add a hooks.enabled setting. (#15933) 2026-01-06 21:33:37 +00:00
Jack Wotherspoon c31f05356a fix: image token estimation (#16004) 2026-01-06 21:19:29 +00:00
Abhi 61dbab03e0 feat(ui): add visual indicators for hook execution (#15408) 2026-01-06 20:52:12 +00:00
Bryan Morgan 86b5995f12 Add workflow to label child issues for rollup (#16002) 2026-01-06 16:00:47 -05:00
Tommaso Sciortino d2849fda8a properly disable keyboard modes on exit (#16006) 2026-01-06 20:11:43 +00:00
Tommaso Sciortino a61fb058b7 fix: writeTodo construction (#16014) 2026-01-06 20:08:25 +00:00
christine betts 7edd803034 Fix settings command fallback (#15926) 2026-01-06 19:50:27 +00:00
N. Taylor Mullen 0c54136244 Agent Skills: Add skill directory to WorkspaceContext upon activation (#15870) 2026-01-06 19:24:37 +00:00
Ishaan Gupta cce4574143 Use GetOperation to poll for OnboardUser completion (#15827)
Co-authored-by: Vedant Mahajan <vedant.04.mahajan@gmail.com>
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-06 19:08:59 +00:00
Sehoon Shon 9172e28315 Add description for each settings item in /settings (#15936) 2026-01-06 19:06:15 +00:00
Gal Zahavi 6f4b2ad0b9 fix: default folder trust to untrusted for enhanced security (#15943) 2026-01-06 18:09:09 +00:00
N. Taylor Mullen 9a3ff6510f feat(policy): allow 'modes' in user and admin policies (#15977) 2026-01-06 17:56:50 +00:00
christine betts 4c67eef0f2 Inform user of missing settings on extensions update (#15944)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-06 17:26:01 +00:00
ppergame 7d4f97de7a fix(core): use correct interactive check for system prompt (#15020)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: Billy Biggs <bbiggs@google.com>
2026-01-06 16:21:36 +00:00
Sehoon Shon cbd2eee9c4 remove manual string when displaying manual model in the footer (#15967) 2026-01-06 04:07:44 +00:00
Tommaso Sciortino 384fb6a465 Add setting to support OSC 52 paste (#15336) 2026-01-06 00:11:50 +00:00
N. Taylor Mullen 2cb33b2f76 Agent Skills: Implement /skills reload (#15865) 2026-01-05 23:12:51 +00:00
139 changed files with 4939 additions and 2393 deletions
+6 -10
View File
@@ -39,22 +39,18 @@ runs:
if: "inputs.dry-run != 'true'"
env:
GH_TOKEN: '${{ inputs.github-token }}'
INPUTS_BRANCH_NAME: ${{ inputs.branch-name }}
INPUTS_PR_TITLE: ${{ inputs.pr-title }}
INPUTS_PR_BODY: ${{ inputs.pr-body }}
INPUTS_BASE_BRANCH: ${{ inputs.base-branch }}
shell: 'bash'
working-directory: '${{ inputs.working-directory }}'
run: |
set -e
if ! git ls-remote --exit-code --heads origin "${INPUTS_BRANCH_NAME}"; then
echo "::error::Branch '${INPUTS_BRANCH_NAME}' does not exist on the remote repository."
if ! git ls-remote --exit-code --heads origin "${{ inputs.branch-name }}"; then
echo "::error::Branch '${{ inputs.branch-name }}' does not exist on the remote repository."
exit 1
fi
PR_URL=$(gh pr create \
--title "${INPUTS_PR_TITLE}" \
--body "${INPUTS_PR_BODY}" \
--base "${INPUTS_BASE_BRANCH}" \
--head "${INPUTS_BRANCH_NAME}" \
--title "${{ inputs.pr-title }}" \
--body "${{ inputs.pr-body }}" \
--base "${{ inputs.base-branch }}" \
--head "${{ inputs.branch-name }}" \
--fill)
gh pr merge "$PR_URL" --auto
+6 -12
View File
@@ -30,22 +30,16 @@ runs:
id: 'npm_auth_token'
shell: 'bash'
run: |
AUTH_TOKEN="${INPUTS_GITHUB_TOKEN}"
PACKAGE_NAME="${INPUTS_PACKAGE_NAME}"
AUTH_TOKEN="${{ inputs.github-token }}"
PACKAGE_NAME="${{ inputs.package-name }}"
PRIVATE_REPO="@google-gemini/"
if [[ "$PACKAGE_NAME" == "$PRIVATE_REPO"* ]]; then
AUTH_TOKEN="${INPUTS_GITHUB_TOKEN}"
AUTH_TOKEN="${{ inputs.github-token }}"
elif [[ "$PACKAGE_NAME" == "@google/gemini-cli" ]]; then
AUTH_TOKEN="${INPUTS_WOMBAT_TOKEN_CLI}"
AUTH_TOKEN="${{ inputs.wombat-token-cli }}"
elif [[ "$PACKAGE_NAME" == "@google/gemini-cli-core" ]]; then
AUTH_TOKEN="${INPUTS_WOMBAT_TOKEN_CORE}"
AUTH_TOKEN="${{ inputs.wombat-token-core }}"
elif [[ "$PACKAGE_NAME" == "@google/gemini-cli-a2a-server" ]]; then
AUTH_TOKEN="${INPUTS_WOMBAT_TOKEN_A2A_SERVER}"
AUTH_TOKEN="${{ inputs.wombat-token-a2a-server }}"
fi
echo "auth-token=$AUTH_TOKEN" >> $GITHUB_OUTPUT
env:
INPUTS_GITHUB_TOKEN: ${{ inputs.github-token }}
INPUTS_PACKAGE_NAME: ${{ inputs.package-name }}
INPUTS_WOMBAT_TOKEN_CLI: ${{ inputs.wombat-token-cli }}
INPUTS_WOMBAT_TOKEN_CORE: ${{ inputs.wombat-token-core }}
INPUTS_WOMBAT_TOKEN_A2A_SERVER: ${{ inputs.wombat-token-a2a-server }}
+20 -41
View File
@@ -90,19 +90,15 @@ runs:
id: 'release_branch'
shell: 'bash'
run: |
BRANCH_NAME="release/${INPUTS_RELEASE_TAG}"
BRANCH_NAME="release/${{ inputs.release-tag }}"
git switch -c "${BRANCH_NAME}"
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
env:
INPUTS_RELEASE_TAG: ${{ inputs.release-tag }}
- name: '⬆️ Update package versions'
working-directory: '${{ inputs.working-directory }}'
shell: 'bash'
run: |
npm run release:version "${INPUTS_RELEASE_VERSION}"
env:
INPUTS_RELEASE_VERSION: ${{ inputs.release-version }}
npm run release:version "${{ inputs.release-version }}"
- name: '💾 Commit and Conditionally Push package versions'
working-directory: '${{ inputs.working-directory }}'
@@ -164,30 +160,23 @@ runs:
working-directory: '${{ inputs.working-directory }}'
env:
NODE_AUTH_TOKEN: '${{ steps.core-token.outputs.auth-token }}'
INPUTS_DRY_RUN: ${{ inputs.dry-run }}
INPUTS_CORE_PACKAGE_NAME: ${{ inputs.core-package-name }}
shell: 'bash'
run: |
npm publish \
--dry-run="${INPUTS_DRY_RUN}" \
--workspace="${INPUTS_CORE_PACKAGE_NAME}" \
--dry-run="${{ inputs.dry-run }}" \
--workspace="${{ inputs.core-package-name }}" \
--no-tag
npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} false --silent
npm dist-tag rm ${{ inputs.core-package-name }} false --silent
- name: '🔗 Install latest core package'
working-directory: '${{ inputs.working-directory }}'
if: "${{ inputs.dry-run != 'true' }}"
shell: 'bash'
run: |
npm install "${INPUTS_CORE_PACKAGE_NAME}@${INPUTS_RELEASE_VERSION}" \
--workspace="${INPUTS_CLI_PACKAGE_NAME}" \
--workspace="${INPUTS_A2A_PACKAGE_NAME}" \
npm install "${{ inputs.core-package-name }}@${{ inputs.release-version }}" \
--workspace="${{ inputs.cli-package-name }}" \
--workspace="${{ inputs.a2a-package-name }}" \
--save-exact
env:
INPUTS_CORE_PACKAGE_NAME: ${{ inputs.core-package-name }}
INPUTS_RELEASE_VERSION: ${{ inputs.release-version }}
INPUTS_CLI_PACKAGE_NAME: ${{ inputs.cli-package-name }}
INPUTS_A2A_PACKAGE_NAME: ${{ inputs.a2a-package-name }}
- name: 'Get CLI Token'
uses: './.github/actions/npm-auth-token'
@@ -203,15 +192,13 @@ runs:
working-directory: '${{ inputs.working-directory }}'
env:
NODE_AUTH_TOKEN: '${{ steps.cli-token.outputs.auth-token }}'
INPUTS_DRY_RUN: ${{ inputs.dry-run }}
INPUTS_CLI_PACKAGE_NAME: ${{ inputs.cli-package-name }}
shell: 'bash'
run: |
npm publish \
--dry-run="${INPUTS_DRY_RUN}" \
--workspace="${INPUTS_CLI_PACKAGE_NAME}" \
--dry-run="${{ inputs.dry-run }}" \
--workspace="${{ inputs.cli-package-name }}" \
--no-tag
npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} false --silent
npm dist-tag rm ${{ inputs.cli-package-name }} false --silent
- name: 'Get a2a-server Token'
uses: './.github/actions/npm-auth-token'
@@ -227,16 +214,14 @@ runs:
working-directory: '${{ inputs.working-directory }}'
env:
NODE_AUTH_TOKEN: '${{ steps.a2a-token.outputs.auth-token }}'
INPUTS_DRY_RUN: ${{ inputs.dry-run }}
INPUTS_A2A_PACKAGE_NAME: ${{ inputs.a2a-package-name }}
shell: 'bash'
# Tag staging for initial release
run: |
npm publish \
--dry-run="${INPUTS_DRY_RUN}" \
--workspace="${INPUTS_A2A_PACKAGE_NAME}" \
--dry-run="${{ inputs.dry-run }}" \
--workspace="${{ inputs.a2a-package-name }}" \
--no-tag
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} false --silent
npm dist-tag rm ${{ inputs.a2a-package-name }} false --silent
- name: '🔬 Verify NPM release by version'
uses: './.github/actions/verify-release'
@@ -270,16 +255,13 @@ runs:
if: "${{ inputs.dry-run != 'true' && inputs.skip-github-release != 'true' && inputs.npm-tag != 'dev' && inputs.npm-registry-url != 'https://npm.pkg.github.com/' }}"
env:
GITHUB_TOKEN: '${{ inputs.github-token }}'
INPUTS_RELEASE_TAG: ${{ inputs.release-tag }}
STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME: ${{ steps.release_branch.outputs.BRANCH_NAME }}
INPUTS_PREVIOUS_TAG: ${{ inputs.previous-tag }}
shell: 'bash'
run: |
gh release create "${INPUTS_RELEASE_TAG}" \
gh release create "${{ inputs.release-tag }}" \
bundle/gemini.js \
--target "${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}" \
--title "Release ${INPUTS_RELEASE_TAG}" \
--notes-start-tag "${INPUTS_PREVIOUS_TAG}" \
--target "${{ steps.release_branch.outputs.BRANCH_NAME }}" \
--title "Release ${{ inputs.release-tag }}" \
--notes-start-tag "${{ inputs.previous-tag }}" \
--generate-notes \
${{ inputs.npm-tag != 'latest' && '--prerelease' || '' }}
@@ -289,8 +271,5 @@ runs:
continue-on-error: true
shell: 'bash'
run: |
echo "Cleaning up release branch ${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}..."
git push origin --delete "${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}"
env:
STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME: ${{ steps.release_branch.outputs.BRANCH_NAME }}
echo "Cleaning up release branch ${{ steps.release_branch.outputs.BRANCH_NAME }}..."
git push origin --delete "${{ steps.release_branch.outputs.BRANCH_NAME }}"
+1 -3
View File
@@ -52,10 +52,8 @@ runs:
id: 'branch_name'
shell: 'bash'
run: |
REF_NAME="${INPUTS_REF_NAME}"
REF_NAME="${{ inputs.ref-name }}"
echo "name=${REF_NAME%/merge}" >> $GITHUB_OUTPUT
env:
INPUTS_REF_NAME: ${{ inputs.ref-name }}
- name: 'Build and Push the Docker Image'
uses: 'docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83' # ratchet:docker/build-push-action@v6
with:
+4 -10
View File
@@ -56,8 +56,8 @@ runs:
id: 'image_tag'
shell: 'bash'
run: |-
SHELL_TAG_NAME="${INPUTS_GITHUB_REF_NAME}"
FINAL_TAG="${INPUTS_GITHUB_SHA}"
SHELL_TAG_NAME="${{ inputs.github-ref-name }}"
FINAL_TAG="${{ inputs.github-sha }}"
if [[ "$SHELL_TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "Release detected."
FINAL_TAG="${SHELL_TAG_NAME#v}"
@@ -66,28 +66,22 @@ runs:
fi
echo "Determined image tag: $FINAL_TAG"
echo "FINAL_TAG=$FINAL_TAG" >> $GITHUB_OUTPUT
env:
INPUTS_GITHUB_REF_NAME: ${{ inputs.github-ref-name }}
INPUTS_GITHUB_SHA: ${{ inputs.github-sha }}
- name: 'build'
id: 'docker_build'
shell: 'bash'
env:
GEMINI_SANDBOX_IMAGE_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}'
GEMINI_SANDBOX: 'docker'
STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG: ${{ steps.image_tag.outputs.FINAL_TAG }}
run: |-
npm run build:sandbox -- \
--image google/gemini-cli-sandbox:${STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG} \
--image google/gemini-cli-sandbox:${{ steps.image_tag.outputs.FINAL_TAG }} \
--output-file final_image_uri.txt
echo "uri=$(cat final_image_uri.txt)" >> $GITHUB_OUTPUT
- name: 'publish'
shell: 'bash'
if: "${{ inputs.dry-run != 'true' }}"
run: |-
docker push "${STEPS_DOCKER_BUILD_OUTPUTS_URI}"
env:
STEPS_DOCKER_BUILD_OUTPUTS_URI: ${{ steps.docker_build.outputs.uri }}
docker push "${{ steps.docker_build.outputs.uri }}"
- name: 'Create issue on failure'
if: |-
${{ failure() }}
+1 -3
View File
@@ -18,7 +18,5 @@ runs:
shell: 'bash'
run: |-
echo ""@google-gemini:registry=https://npm.pkg.github.com"" > ~/.npmrc
echo ""//npm.pkg.github.com/:_authToken=${INPUTS_GITHUB_TOKEN}"" >> ~/.npmrc
echo ""//npm.pkg.github.com/:_authToken=${{ inputs.github-token }}"" >> ~/.npmrc
echo ""@google:registry=https://wombat-dressing-room.appspot.com"" >> ~/.npmrc
env:
INPUTS_GITHUB_TOKEN: ${{ inputs.github-token }}
+4 -24
View File
@@ -71,13 +71,10 @@ runs:
${{ inputs.dry-run != 'true' }}
env:
NODE_AUTH_TOKEN: '${{ steps.core-token.outputs.auth-token }}'
INPUTS_CORE_PACKAGE_NAME: ${{ inputs.core-package-name }}
INPUTS_VERSION: ${{ inputs.version }}
INPUTS_CHANNEL: ${{ inputs.channel }}
shell: 'bash'
working-directory: '${{ inputs.working-directory }}'
run: |
npm dist-tag add ${INPUTS_CORE_PACKAGE_NAME}@${INPUTS_VERSION} ${INPUTS_CHANNEL}
npm dist-tag add ${{ inputs.core-package-name }}@${{ inputs.version }} ${{ inputs.channel }}
- name: 'Get cli Token'
uses: './.github/actions/npm-auth-token'
@@ -94,13 +91,10 @@ runs:
${{ inputs.dry-run != 'true' }}
env:
NODE_AUTH_TOKEN: '${{ steps.cli-token.outputs.auth-token }}'
INPUTS_CLI_PACKAGE_NAME: ${{ inputs.cli-package-name }}
INPUTS_VERSION: ${{ inputs.version }}
INPUTS_CHANNEL: ${{ inputs.channel }}
shell: 'bash'
working-directory: '${{ inputs.working-directory }}'
run: |
npm dist-tag add ${INPUTS_CLI_PACKAGE_NAME}@${INPUTS_VERSION} ${INPUTS_CHANNEL}
npm dist-tag add ${{ inputs.cli-package-name }}@${{ inputs.version }} ${{ inputs.channel }}
- name: 'Get a2a Token'
uses: './.github/actions/npm-auth-token'
@@ -117,13 +111,10 @@ runs:
${{ inputs.dry-run == 'false' }}
env:
NODE_AUTH_TOKEN: '${{ steps.a2a-token.outputs.auth-token }}'
INPUTS_A2A_PACKAGE_NAME: ${{ inputs.a2a-package-name }}
INPUTS_VERSION: ${{ inputs.version }}
INPUTS_CHANNEL: ${{ inputs.channel }}
shell: 'bash'
working-directory: '${{ inputs.working-directory }}'
run: |
npm dist-tag add ${INPUTS_A2A_PACKAGE_NAME}@${INPUTS_VERSION} ${INPUTS_CHANNEL}
npm dist-tag add ${{ inputs.a2a-package-name }}@${{ inputs.version }} ${{ inputs.channel }}
- name: 'Log dry run'
if: |-
@@ -131,15 +122,4 @@ runs:
shell: 'bash'
working-directory: '${{ inputs.working-directory }}'
run: |
echo "Dry run: Would have added tag '${INPUTS_CHANNEL}' to version '${INPUTS_VERSION}' for ${INPUTS_CLI_PACKAGE_NAME}, ${INPUTS_CORE_PACKAGE_NAME}, and ${INPUTS_A2A_PACKAGE_NAME}."
env:
INPUTS_CHANNEL: ${{ inputs.channel }}
INPUTS_VERSION: ${{ inputs.version }}
INPUTS_CLI_PACKAGE_NAME: ${{ inputs.cli-package-name }}
INPUTS_CORE_PACKAGE_NAME: ${{ inputs.core-package-name }}
INPUTS_A2A_PACKAGE_NAME: ${{ inputs.a2a-package-name }}
echo "Dry run: Would have added tag '${{ inputs.channel }}' to version '${{ inputs.version }}' for ${{ inputs.cli-package-name }}, ${{ inputs.core-package-name }}, and ${{ inputs.a2a-package-name }}."
+5 -11
View File
@@ -64,13 +64,10 @@ runs:
working-directory: '${{ inputs.working-directory }}'
run: |-
gemini_version=$(gemini --version)
if [ "$gemini_version" != "${INPUTS_EXPECTED_VERSION}" ]; then
echo "❌ NPM Version mismatch: Got $gemini_version from ${INPUTS_NPM_PACKAGE}, expected ${INPUTS_EXPECTED_VERSION}"
if [ "$gemini_version" != "${{ inputs.expected-version }}" ]; then
echo "❌ NPM Version mismatch: Got $gemini_version from ${{ inputs.npm-package }}, expected ${{ inputs.expected-version }}"
exit 1
fi
env:
INPUTS_EXPECTED_VERSION: ${{ inputs.expected-version }}
INPUTS_NPM_PACKAGE: ${{ inputs.npm-package }}
- name: 'Clear npm cache'
shell: 'bash'
@@ -80,14 +77,11 @@ runs:
shell: 'bash'
working-directory: '${{ inputs.working-directory }}'
run: |-
gemini_version=$(npx --prefer-online "${INPUTS_NPM_PACKAGE}" --version)
if [ "$gemini_version" != "${INPUTS_EXPECTED_VERSION}" ]; then
echo "❌ NPX Run Version mismatch: Got $gemini_version from ${INPUTS_NPM_PACKAGE}, expected ${INPUTS_EXPECTED_VERSION}"
gemini_version=$(npx --prefer-online "${{ inputs.npm-package}}" --version)
if [ "$gemini_version" != "${{ inputs.expected-version }}" ]; then
echo "❌ NPX Run Version mismatch: Got $gemini_version from ${{ inputs.npm-package }}, expected ${{ inputs.expected-version }}"
exit 1
fi
env:
INPUTS_NPM_PACKAGE: ${{ inputs.npm-package }}
INPUTS_EXPECTED_VERSION: ${{ inputs.expected-version }}
- name: 'Install dependencies for integration tests'
shell: 'bash'
+3 -6
View File
@@ -53,7 +53,7 @@ jobs:
REPO_NAME: '${{ github.event.inputs.repo_name }}'
run: |
mkdir -p ./pr
echo '${REPO_NAME}' > ./pr/repo_name
echo '${{ env.REPO_NAME }}' > ./pr/repo_name
- uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
with:
name: 'repo_name'
@@ -288,15 +288,12 @@ jobs:
steps:
- name: 'Check E2E test results'
run: |
if [[ ${NEEDS_E2E_LINUX_RESULT} != 'success' || \
${NEEDS_E2E_MAC_RESULT} != 'success' ]]; then
if [[ ${{ needs.e2e_linux.result }} != 'success' || \
${{ needs.e2e_mac.result }} != 'success' ]]; then
echo "One or more E2E jobs failed."
exit 1
fi
echo "All required E2E jobs passed!"
env:
NEEDS_E2E_LINUX_RESULT: ${{ needs.e2e_linux.result }}
NEEDS_E2E_MAC_RESULT: ${{ needs.e2e_mac.result }}
set_workflow_status:
runs-on: 'gemini-cli-ubuntu-16-core'
+6 -13
View File
@@ -380,20 +380,13 @@ jobs:
steps:
- name: 'Check all job results'
run: |
if [[ (${NEEDS_LINT_RESULT} != 'success' && ${NEEDS_LINT_RESULT} != 'skipped') || \
(${NEEDS_LINK_CHECKER_RESULT} != 'success' && ${NEEDS_LINK_CHECKER_RESULT} != 'skipped') || \
(${NEEDS_TEST_LINUX_RESULT} != 'success' && ${NEEDS_TEST_LINUX_RESULT} != 'skipped') || \
(${NEEDS_TEST_MAC_RESULT} != 'success' && ${NEEDS_TEST_MAC_RESULT} != 'skipped') || \
(${NEEDS_CODEQL_RESULT} != 'success' && ${NEEDS_CODEQL_RESULT} != 'skipped') || \
(${NEEDS_BUNDLE_SIZE_RESULT} != 'success' && ${NEEDS_BUNDLE_SIZE_RESULT} != 'skipped') ]]; then
if [[ (${{ needs.lint.result }} != 'success' && ${{ needs.lint.result }} != 'skipped') || \
(${{ needs.link_checker.result }} != 'success' && ${{ needs.link_checker.result }} != 'skipped') || \
(${{ needs.test_linux.result }} != 'success' && ${{ needs.test_linux.result }} != 'skipped') || \
(${{ needs.test_mac.result }} != 'success' && ${{ needs.test_mac.result }} != 'skipped') || \
(${{ needs.codeql.result }} != 'success' && ${{ needs.codeql.result }} != 'skipped') || \
(${{ needs.bundle_size.result }} != 'success' && ${{ needs.bundle_size.result }} != 'skipped') ]]; then
echo "One or more CI jobs failed."
exit 1
fi
echo "All CI jobs passed!"
env:
NEEDS_LINT_RESULT: ${{ needs.lint.result }}
NEEDS_LINK_CHECKER_RESULT: ${{ needs.link_checker.result }}
NEEDS_TEST_LINUX_RESULT: ${{ needs.test_linux.result }}
NEEDS_TEST_MAC_RESULT: ${{ needs.test_mac.result }}
NEEDS_CODEQL_RESULT: ${{ needs.codeql.result }}
NEEDS_BUNDLE_SIZE_RESULT: ${{ needs.bundle_size.result }}
+5 -5
View File
@@ -68,10 +68,10 @@ jobs:
VERBOSE: 'true'
shell: 'bash'
run: |
if [[ "${IS_DOCKER}" == "true" ]]; then
npm run deflake:test:integration:sandbox:docker -- --runs="${RUNS}" -- --testNamePattern "'${TEST_NAME_PATTERN}'"
if [[ "${{ env.IS_DOCKER }}" == "true" ]]; then
npm run deflake:test:integration:sandbox:docker -- --runs="${{ env.RUNS }}" -- --testNamePattern "'${{ env.TEST_NAME_PATTERN }}'"
else
npm run deflake:test:integration:sandbox:none -- --runs="${RUNS}" -- --testNamePattern "'${TEST_NAME_PATTERN}'"
npm run deflake:test:integration:sandbox:none -- --runs="${{ env.RUNS }}" -- --testNamePattern "'${{ env.TEST_NAME_PATTERN }}'"
fi
deflake_e2e_mac:
@@ -109,7 +109,7 @@ jobs:
TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}'
VERBOSE: 'true'
run: |
npm run deflake:test:integration:sandbox:none -- --runs="${RUNS}" -- --testNamePattern "'${TEST_NAME_PATTERN}'"
npm run deflake:test:integration:sandbox:none -- --runs="${{ env.RUNS }}" -- --testNamePattern "'${{ env.TEST_NAME_PATTERN }}'"
deflake_e2e_windows:
name: 'Slow E2E - Win'
@@ -167,4 +167,4 @@ jobs:
TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}'
shell: 'pwsh'
run: |
npm run deflake:test:integration:sandbox:none -- --runs="$env:RUNS" -- --testNamePattern "'$env:TEST_NAME_PATTERN'"
npm run deflake:test:integration:sandbox:none -- --runs="${{ env.RUNS }}" -- --testNamePattern "'${{ env.TEST_NAME_PATTERN }}'"
+1 -1
View File
@@ -44,5 +44,5 @@ jobs:
- name: 'Run evaluation'
working-directory: '/app'
run: |
poetry run exp_run --experiment-mode=on-demand --branch-or-commit=${GITHUB_REF_NAME} --model-name=gemini-2.5-pro --dataset=swebench_verified --concurrency=15
poetry run exp_run --experiment-mode=on-demand --branch-or-commit=${{ github.ref_name }} --model-name=gemini-2.5-pro --dataset=swebench_verified --concurrency=15
poetry run python agent_prototypes/scripts/parse_gcli_logs_experiment.py --experiment_dir=experiments/adhoc/gcli_temp_exp --gcs-bucket="${EVAL_GCS_BUCKET}" --gcs-path=gh_action_artifacts
@@ -39,7 +39,6 @@ jobs:
github.event_name == 'workflow_dispatch' ||
(
(github.event_name == 'issues' || github.event_name == 'issue_comment') &&
contains(github.event.issue.labels.*.name, 'status/need-triage') &&
(github.event_name != 'issue_comment' || (
contains(github.event.comment.body, '@gemini-cli /triage') &&
(github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR')
@@ -75,11 +74,6 @@ jobs:
ISSUE_NUMBER_INPUT: '${{ github.event.inputs.issue_number }}'
LABELS: '${{ steps.get_issue_data.outputs.labels }}'
run: |
if ! echo "${LABELS}" | grep -q 'status/need-triage'; then
echo "Issue #${ISSUE_NUMBER_INPUT} does not have the 'status/need-triage' label. Stopping workflow."
exit 1
fi
if echo "${LABELS}" | grep -q 'area/'; then
echo "Issue #${ISSUE_NUMBER_INPUT} already has 'area/' label. Stopping workflow."
exit 1
@@ -45,11 +45,11 @@ jobs:
echo '🔍 Finding issues without labels...'
NO_LABEL_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue no:label' --json number,title,body)"
--search 'is:open is:issue no:label' --limit 10 --json number,title,body)"
echo '🏷️ Finding issues that need triage...'
NEED_TRIAGE_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
--search "is:open is:issue label:\"status/need-triage\"" --limit 1000 --json number,title,body)"
--search "is:open is:issue label:\"status/need-triage\"" --limit 10 --json number,title,body)"
echo '🔄 Merging and deduplicating issues...'
ISSUES="$(echo "${NO_LABEL_ISSUES}" "${NEED_TRIAGE_ISSUES}" | jq -c -s 'add | unique_by(.number)')"
@@ -0,0 +1,40 @@
name: 'Label Child Issues for Project Rollup'
on:
issues:
types: ['opened', 'edited', 'reopened']
jobs:
labeler:
runs-on: 'ubuntu-latest'
permissions:
issues: 'write'
steps:
- name: 'Check for Parent Workstream and Apply Label'
uses: 'actions/github-script@v7'
with:
script: |
const issue = context.payload.issue;
const labelToAdd = 'workstream-rollup';
// --- Define the FULL URLs of the allowed parent workstreams ---
const allowedParentUrls = [
'https://api.github.com/repos/google-gemini/gemini-cli/issues/15374',
'https://api.github.com/repos/google-gemini/gemini-cli/issues/15456',
'https://api.github.com/repos/google-gemini/gemini-cli/issues/15324'
];
// Check if the issue has a parent_issue_url and if it's in our allowed list.
if (issue && issue.parent_issue_url && allowedParentUrls.includes(issue.parent_issue_url)) {
console.log(`SUCCESS: Issue #${issue.number} is a child of a target workstream (${issue.parent_issue_url}). Adding label.`);
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [labelToAdd]
});
} else if (issue && issue.parent_issue_url) {
console.log(`FAILURE: Issue #${issue.number} has a parent, but it's not a target workstream. Parent URL: ${issue.parent_issue_url}`);
} else {
console.log(`FAILURE: Issue #${issue.number} is not a child of any issue. No action taken.`);
}
+5 -11
View File
@@ -118,7 +118,6 @@ jobs:
ORIGINAL_RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}'
ORIGINAL_RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}'
ORIGINAL_PREVIOUS_TAG: '${{ steps.patch_version.outputs.PREVIOUS_TAG }}'
VARS_CLI_PACKAGE_NAME: ${{vars.CLI_PACKAGE_NAME}}
run: |
echo "🔍 Verifying no concurrent patch releases have occurred..."
@@ -130,7 +129,7 @@ jobs:
# Re-run the same version calculation script
echo "Re-calculating version to check for changes..."
CURRENT_PATCH_JSON=$(node scripts/get-release-version.js --cli-package-name="${VARS_CLI_PACKAGE_NAME}" --type=patch --patch-from="${CHANNEL}")
CURRENT_PATCH_JSON=$(node scripts/get-release-version.js --cli-package-name="${{vars.CLI_PACKAGE_NAME}}" --type=patch --patch-from="${CHANNEL}")
CURRENT_RELEASE_VERSION=$(echo "${CURRENT_PATCH_JSON}" | jq -r .releaseVersion)
CURRENT_RELEASE_TAG=$(echo "${CURRENT_PATCH_JSON}" | jq -r .releaseTag)
CURRENT_PREVIOUS_TAG=$(echo "${CURRENT_PATCH_JSON}" | jq -r .previousReleaseTag)
@@ -163,15 +162,10 @@ jobs:
- name: 'Print Calculated Version'
run: |-
echo "Patch Release Summary:"
echo " Release Version: ${STEPS_PATCH_VERSION_OUTPUTS_RELEASE_VERSION}"
echo " Release Tag: ${STEPS_PATCH_VERSION_OUTPUTS_RELEASE_TAG}"
echo " NPM Tag: ${STEPS_PATCH_VERSION_OUTPUTS_NPM_TAG}"
echo " Previous Tag: ${STEPS_PATCH_VERSION_OUTPUTS_PREVIOUS_TAG}"
env:
STEPS_PATCH_VERSION_OUTPUTS_RELEASE_VERSION: ${{ steps.patch_version.outputs.RELEASE_VERSION }}
STEPS_PATCH_VERSION_OUTPUTS_RELEASE_TAG: ${{ steps.patch_version.outputs.RELEASE_TAG }}
STEPS_PATCH_VERSION_OUTPUTS_NPM_TAG: ${{ steps.patch_version.outputs.NPM_TAG }}
STEPS_PATCH_VERSION_OUTPUTS_PREVIOUS_TAG: ${{ steps.patch_version.outputs.PREVIOUS_TAG }}
echo " Release Version: ${{ steps.patch_version.outputs.RELEASE_VERSION }}"
echo " Release Tag: ${{ steps.patch_version.outputs.RELEASE_TAG }}"
echo " NPM Tag: ${{ steps.patch_version.outputs.NPM_TAG }}"
echo " Previous Tag: ${{ steps.patch_version.outputs.PREVIOUS_TAG }}"
- name: 'Run Tests'
if: "${{github.event.inputs.force_skip_tests != 'true'}}"
+3 -8
View File
@@ -360,28 +360,23 @@ jobs:
- name: 'Create and switch to a new branch'
id: 'release_branch'
run: |
BRANCH_NAME="chore/nightly-version-bump-${NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION}"
BRANCH_NAME="chore/nightly-version-bump-${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}"
git switch -c "${BRANCH_NAME}"
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
env:
NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION: ${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}
- name: 'Update package versions'
run: 'npm run release:version "${NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION}"'
env:
NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION: ${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}
run: 'npm run release:version "${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}"'
- name: 'Commit and Push package versions'
env:
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
DRY_RUN: '${{ github.event.inputs.dry_run }}'
NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION: ${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}
run: |-
git add package.json packages/*/package.json
if [ -f package-lock.json ]; then
git add package-lock.json
fi
git commit -m "chore(release): bump version to ${NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION}"
git commit -m "chore(release): bump version to ${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}"
if [[ "${DRY_RUN}" == "false" ]]; then
echo "Pushing release branch to remote..."
git push --set-upstream origin "${BRANCH_NAME}"
+2 -2
View File
@@ -23,8 +23,8 @@ jobs:
HEAD_SHA: '${{ github.event.inputs.head_sha || github.event.pull_request.head.sha }}'
run: |
mkdir -p ./pr
echo '${REPO_NAME}' > ./pr/repo_name
echo '${HEAD_SHA}' > ./pr/head_sha
echo '${{ env.REPO_NAME }}' > ./pr/repo_name
echo '${{ env.HEAD_SHA }}' > ./pr/head_sha
- uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
with:
name: 'repo_name'
+1
View File
@@ -111,3 +111,4 @@ they appear in the UI.
| ----------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------ | ------- |
| Enable Codebase Investigator | `experimental.codebaseInvestigatorSettings.enabled` | Enable the Codebase Investigator agent. | `true` |
| Codebase Investigator Max Num Turns | `experimental.codebaseInvestigatorSettings.maxNumTurns` | Maximum number of turns for the Codebase Investigator agent. | `10` |
| Agent Skills | `experimental.skills` | Enable Agent Skills (experimental). | `false` |
+156
View File
@@ -0,0 +1,156 @@
# Agent Skills
_Note: This is an experimental feature enabled via `experimental.skills`. You
can also search for "Skills" within the `/settings` interactive UI to toggle
this and manage other skill-related settings._
Agent Skills allow you to extend Gemini CLI with specialized expertise,
procedural workflows, and task-specific resources. Based on the
[Agent Skills](https://agentskills.io) open standard, a "skill" is a
self-contained directory that packages instructions and assets into a
discoverable capability.
## Overview
Unlike general context files ([`GEMINI.md`](./gemini-md.md)), which provide
persistent project-wide background, Skills represent **on-demand expertise**.
This allows Gemini to maintain a vast library of specialized capabilities—such
as security auditing, cloud deployments, or codebase migrations—without
cluttering the model's immediate context window.
Gemini autonomously decides when to employ a skill based on your request and the
skill's description. When a relevant skill is identified, the model "pulls in"
the full instructions and resources required to complete the task using the
`activate_skill` tool.
## Key Benefits
- **Shared Expertise:** Package complex workflows (like a specific team's PR
review process) into a folder that anyone can use.
- **Repeatable Workflows:** Ensure complex multi-step tasks are performed
consistently by providing a procedural framework.
- **Resource Bundling:** Include scripts, templates, or example data alongside
instructions so the agent has everything it needs.
- **Progressive Disclosure:** Only skill metadata (name and description) is
loaded initially. Detailed instructions and resources are only disclosed when
the model explicitly activates the skill, saving context tokens.
## Skill Discovery Tiers
Gemini CLI discovers skills from three primary locations:
1. **Project Skills** (`.gemini/skills/`): Project-specific skills that are
typically committed to version control and shared with the team.
2. **User Skills** (`~/.gemini/skills/`): Personal skills available across all
your projects.
3. **Extension Skills**: Skills bundled within installed
[extensions](../extensions/index.md).
**Precedence:** If multiple skills share the same name, higher-precedence
locations override lower ones: **Project > User > Extension**.
## Managing Skills
### In an Interactive Session
Use the `/skills` slash command to view and manage available expertise:
- `/skills list` (default): Shows all discovered skills and their status.
- `/skills disable <name>`: Prevents a specific skill from being used.
- `/skills enable <name>`: Re-enables a disabled skill.
- `/skills reload`: Refreshes the list of discovered skills from all tiers.
_Note: `/skills disable` and `/skills enable` default to the `user` scope. Use
`--scope project` to manage project-specific settings._
### From the Terminal
The `gemini skills` command provides management utilities:
```bash
# List all discovered skills
gemini skills list
# Enable/disable skills. Can use --scope to specify project or user
gemini skills enable my-expertise
gemini skills disable my-expertise
```
## Creating a Skill
A skill is a directory containing a `SKILL.md` file at its root. This file uses
YAML frontmatter for metadata and Markdown for instructions.
### Basic Structure
```markdown
---
name: <unique-name>
description: <what the skill does and when Gemini should use it>
---
<your instructions for how the agent should behave / use the skill>
```
- **`name`**: A unique identifier (lowercase, alphanumeric, and dashes).
- **`description`**: The most critical field. Gemini uses this to decide when
the skill is relevant. Be specific about the expertise provided.
- **Body**: Everything below the second `---` is injected as expert procedural
guidance for the model.
### Example: Team Code Reviewer
```markdown
---
name: code-reviewer
description:
Expertise in reviewing code for style, security, and performance. Use when the
user asks for "feedback," a "review," or to "check" their changes.
---
# Code Reviewer
You are an expert code reviewer. When reviewing code, follow this workflow:
1. **Analyze**: Review the staged changes or specific files provided. Ensure
that the changes are scoped properly and represent minimal changes required
to address the issue.
2. **Style**: Ensure code follows the project's conventions and idiomatic
patterns as described in the `GEMINI.md` file.
3. **Security**: Flag any potential security vulnerabilities.
4. **Tests**: Verify that new logic has corresponding test coverage and that
the test coverage adequately validates the changes.
Provide your feedback as a concise bulleted list of "Strengths" and
"Opportunities."
```
### Resource Conventions
While you can structure your skill directory however you like, the Agent Skills
standard encourages these conventions:
- **`scripts/`**: Executable scripts (bash, python, node) the agent can run.
- **`references/`**: Static documentation, schemas, or example data for the
agent to consult.
- **`assets/`**: Code templates, boilerplate, or binary resources.
When a skill is activated, Gemini CLI provides the model with a tree view of the
entire skill directory, allowing it to discover and utilize these assets.
## How it Works (Security & Privacy)
1. **Discovery**: At the start of a session, Gemini CLI scans the discovery
tiers and injects the name and description of all enabled skills into the
system prompt.
2. **Activation**: When Gemini identifies a task matching a skill's
description, it calls the `activate_skill` tool.
3. **Consent**: You will see a confirmation prompt in the UI detailing the
skill's name, purpose, and the directory path it will gain access to.
4. **Injection**: Upon your approval:
- The `SKILL.md` body and folder structure is added to the conversation
history.
- The skill's directory is added to the agent's allowed file paths, granting
it permission to read any bundled assets.
5. **Execution**: The model proceeds with the specialized expertise active. It
is instructed to prioritize the skill's procedural guidance within reason.
+4
View File
@@ -2,6 +2,10 @@
This page contains tutorials for interacting with Gemini CLI.
## Agent Skills
- [Getting Started with Agent Skills](./tutorials/skills-getting-started.md)
## Setting up a Model Context Protocol (MCP) server
> [!CAUTION] Before using a third-party MCP server, ensure you trust its source
@@ -0,0 +1,124 @@
# Getting Started with Agent Skills
Agent Skills allow you to extend Gemini CLI with specialized expertise. This
tutorial will guide you through creating your first skill, enabling it, and
using it in a session.
## 1. Enable Agent Skills
Agent Skills are currently an experimental feature and must be enabled in your
settings.
### Via the interactive UI
1. Start a Gemini CLI session by running `gemini`.
2. Type `/settings` to open the interactive settings dialog.
3. Search for "Skills".
4. Toggle **Agent Skills** to `true`.
5. Press `Esc` to save and exit. You may need to restart the CLI for the
changes to take effect.
### Via `settings.json`
Alternatively, you can manually edit your global settings file at
`~/.gemini/settings.json` (create it if it doesn't exist):
```json
{
"experimental": {
"skills": true
}
}
```
## 2. Create Your First Skill
A skill is a directory containing a `SKILL.md` file. Let's create an **API
Auditor** skill that helps you verify if local or remote endpoints are
responding correctly.
1. **Create the skill directory structure:**
```bash
mkdir -p .gemini/skills/api-auditor/scripts
```
2. **Create the `SKILL.md` file:** Create a file at
`.gemini/skills/api-auditor/SKILL.md` with the following content:
```markdown
---
name: api-auditor
description:
Expertise in auditing and testing API endpoints. Use when the user asks to
"check", "test", or "audit" a URL or API.
---
# API Auditor Instructions
You act as a QA engineer specialized in API reliability. When this skill is
active, you MUST:
1. **Audit**: Use the bundled `scripts/audit.js` utility to check the
status of the provided URL.
2. **Report**: Analyze the output (status codes, latency) and explain any
failures in plain English.
3. **Secure**: Remind the user if they are testing a sensitive endpoint
without an `https://` protocol.
```
3. **Create the bundled Node.js script:** Create a file at
`.gemini/skills/api-auditor/scripts/audit.js`. This script will be used by
the agent to perform the actual check:
```javascript
// .gemini/skills/api-auditor/scripts/audit.js
const url = process.argv[2];
if (!url) {
console.error('Usage: node audit.js <url>');
process.exit(1);
}
console.log(`Auditing ${url}...`);
fetch(url, { method: 'HEAD' })
.then((r) => console.log(`Result: Success (Status ${r.status})`))
.catch((e) => console.error(`Result: Failed (${e.message})`));
```
## 3. Verify the Skill is Discovered
Use the `/skills` slash command (or `gemini skills list` from your terminal) to
see if Gemini CLI has found your new skill.
In a Gemini CLI session:
```
/skills list
```
You should see `api-auditor` in the list of available skills.
## 4. Use the Skill in a Chat
Now, let's see the skill in action. Start a new session and ask a question about
an endpoint.
**User:** "Can you audit http://geminili.com"
Gemini will recognize the request matches the `api-auditor` description and will
ask for your permission to activate it.
**Model:** (After calling `activate_skill`) "I've activated the **api-auditor**
skill. I'll run the audit script now..."
Gemini will then use the `run_shell_command` tool to execute your bundled Node
script:
`node .gemini/skills/api-auditor/scripts/audit.js http://geminili.com`
## Next Steps
- Explore [Agent Skills Authoring Guide](../skills.md#creating-a-skill) to learn
about more advanced skill features.
- Learn how to share skills via [Extensions](../../extensions/index.md).
+32 -5
View File
@@ -685,11 +685,9 @@ their corresponding top-level category object in your `settings.json` file.
- **Requires restart:** Yes
- **`tools.enableHooks`** (boolean):
- **Description:** Enable the hooks system for intercepting and customizing
Gemini CLI behavior. When enabled, hooks configured in settings will execute
at appropriate lifecycle events (BeforeTool, AfterTool, BeforeModel, etc.).
Requires MessageBus integration.
- **Default:** `false`
- **Description:** Enables the hooks system experiment. When disabled, the
hooks system is completely deactivated regardless of other settings.
- **Default:** `true`
- **Requires restart:** Yes
#### `mcp`
@@ -848,6 +846,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `"auto"`
- **Requires restart:** Yes
- **`experimental.useOSC52Paste`** (boolean):
- **Description:** Use OSC 52 sequence for pasting instead of clipboardy
(useful for remote sessions).
- **Default:** `false`
- **`experimental.introspectionAgentSettings.enabled`** (boolean):
- **Description:** Enable the Introspection Agent.
- **Default:** `false`
@@ -862,11 +865,20 @@ their corresponding top-level category object in your `settings.json` file.
#### `hooks`
- **`hooks.enabled`** (boolean):
- **Description:** Canonical toggle for the hooks system. When disabled, no
hooks will be executed.
- **Default:** `false`
- **`hooks.disabled`** (array):
- **Description:** List of hook names (commands) that should be disabled.
Hooks in this list will not execute even if configured.
- **Default:** `[]`
- **`hooks.notifications`** (boolean):
- **Description:** Show visual indicators when hooks are executing.
- **Default:** `true`
- **`hooks.BeforeTool`** (array):
- **Description:** Hooks that execute before tool execution. Can intercept,
validate, or modify tool calls.
@@ -921,6 +933,21 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Hooks that execute before tool selection. Can filter or
prioritize available tools dynamically.
- **Default:** `[]`
#### `admin`
- **`admin.secureModeEnabled`** (boolean):
- **Description:** If true, disallows yolo mode from being used.
- **Default:** `false`
- **`admin.extensions.enabled`** (boolean):
- **Description:** If false, disallows extensions from being installed or
used.
- **Default:** `true`
- **`admin.mcp.enabled`** (boolean):
- **Description:** If false, disallows MCP servers from being used.
- **Default:** `true`
<!-- SETTINGS-AUTOGEN:END -->
#### `mcpServers`
+4
View File
@@ -88,6 +88,10 @@
"label": "Session Management",
"slug": "docs/cli/session-management"
},
{
"label": "Agent Skills",
"slug": "docs/cli/skills"
},
{
"label": "Settings",
"slug": "docs/cli/settings"
+3 -9
View File
@@ -53,10 +53,8 @@ describe('Hooks Agent Flow', () => {
await rig.setup('should inject additional context via BeforeAgent hook', {
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeAgent: [
{
hooks: [
@@ -118,10 +116,8 @@ describe('Hooks Agent Flow', () => {
await rig.setup('should receive prompt and response in AfterAgent hook', {
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
AfterAgent: [
{
hooks: [
@@ -167,10 +163,8 @@ describe('Hooks Agent Flow', () => {
'hooks-agent-flow-multistep.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeAgent: [
{
hooks: [
+23 -67
View File
@@ -32,10 +32,8 @@ describe('Hooks System Integration', () => {
'hooks-system.block-tool.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
matcher: 'write_file',
@@ -86,10 +84,8 @@ describe('Hooks System Integration', () => {
'hooks-system.allow-tool.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
matcher: 'write_file',
@@ -136,10 +132,8 @@ describe('Hooks System Integration', () => {
'hooks-system.after-tool-context.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
AfterTool: [
{
matcher: 'read_file',
@@ -211,10 +205,8 @@ console.log(JSON.stringify({
await rig.setup('should modify LLM requests with BeforeModel hooks', {
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeModel: [
{
hooks: [
@@ -294,10 +286,8 @@ console.log(JSON.stringify({
await rig.setup('should modify LLM responses with AfterModel hooks', {
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
AfterModel: [
{
hooks: [
@@ -347,10 +337,8 @@ console.log(JSON.stringify({
{
settings: {
debugMode: true,
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeToolSelection: [
{
hooks: [
@@ -415,10 +403,8 @@ console.log(JSON.stringify({
await rig.setup('should augment prompts with BeforeAgent hooks', {
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeAgent: [
{
hooks: [
@@ -460,11 +446,11 @@ console.log(JSON.stringify({
settings: {
// Configure tools to enable hooks and require confirmation to trigger notifications
tools: {
enableHooks: true,
approval: 'ASK', // Disable YOLO mode to show permission prompts
confirmationRequired: ['run_shell_command'],
},
hooks: {
enabled: true,
Notification: [
{
matcher: 'ToolPermission',
@@ -554,10 +540,8 @@ console.log(JSON.stringify({
'hooks-system.sequential-execution.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeAgent: [
{
sequential: true,
@@ -636,10 +620,8 @@ try {
await rig.setup('should provide correct input format to hooks', {
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
hooks: [
@@ -689,10 +671,8 @@ try {
'hooks-system.multiple-events.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeAgent: [
{
hooks: [
@@ -804,10 +784,8 @@ try {
await rig.setup('should handle hook failures gracefully', {
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
hooks: [
@@ -858,10 +836,8 @@ try {
'hooks-system.telemetry.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
hooks: [
@@ -901,10 +877,8 @@ try {
'hooks-system.session-startup.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'startup',
@@ -974,10 +948,8 @@ console.log(JSON.stringify({
await rig.setup('should fire SessionStart hook and inject context', {
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'startup',
@@ -1059,10 +1031,8 @@ console.log(JSON.stringify({
'should fire SessionStart hook and display systemMessage in interactive mode',
{
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'startup',
@@ -1129,10 +1099,8 @@ console.log(JSON.stringify({
'hooks-system.session-clear.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
SessionEnd: [
{
matcher: '*',
@@ -1303,10 +1271,8 @@ console.log(JSON.stringify({
'hooks-system.compress-auto.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
PreCompress: [
{
matcher: 'auto',
@@ -1370,10 +1336,8 @@ console.log(JSON.stringify({
'hooks-system.session-startup.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
SessionEnd: [
{
matcher: 'exit',
@@ -1470,10 +1434,8 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
await rig.setup('should not execute hooks disabled in settings file', {
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
hooks: [
@@ -1552,10 +1514,8 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
'should respect disabled hooks across multiple operations',
{
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
hooks: [
@@ -1664,10 +1624,8 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
'hooks-system.input-modification.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
matcher: 'write_file',
@@ -1751,10 +1709,8 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
'hooks-system.before-tool-stop.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
matcher: 'write_file',
+3 -1
View File
@@ -331,7 +331,9 @@ export class TestRig {
ui: {
useAlternateBuffer: true,
},
model: DEFAULT_GEMINI_MODEL,
model: {
name: DEFAULT_GEMINI_MODEL,
},
sandbox:
env['GEMINI_SANDBOX'] !== 'false' ? env['GEMINI_SANDBOX'] : false,
// Don't show the IDE connection dialog when running from VsCode
+12 -12
View File
@@ -1,17 +1,17 @@
{
"name": "@google/gemini-cli",
"version": "0.24.0-nightly.20251227.37be16243",
"version": "0.24.0-preview.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.24.0-nightly.20251227.37be16243",
"version": "0.24.0-preview.2",
"workspaces": [
"packages/*"
],
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.6",
"ink": "npm:@jrichman/ink@6.4.7",
"latest-version": "^9.0.0",
"simple-git": "^3.28.0"
},
@@ -10947,9 +10947,9 @@
},
"node_modules/ink": {
"name": "@jrichman/ink",
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.6.tgz",
"integrity": "sha512-QHl6l1cl3zPCaRMzt9TUbTX6Q5SzvkGEZDDad0DmSf5SPmT1/90k6pGPejEvDCJprkitwObXpPaTWGHItqsy4g==",
"version": "6.4.7",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz",
"integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==",
"license": "MIT",
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
@@ -18471,7 +18471,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.24.0-nightly.20251227.37be16243",
"version": "0.24.0-preview.2",
"dependencies": {
"@a2a-js/sdk": "^0.3.7",
"@google-cloud/storage": "^7.16.0",
@@ -18781,7 +18781,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.24.0-nightly.20251227.37be16243",
"version": "0.24.0-preview.2",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.11.0",
@@ -18800,7 +18800,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.6",
"ink": "npm:@jrichman/ink@6.4.7",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -18884,7 +18884,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.24.0-nightly.20251227.37be16243",
"version": "0.24.0-preview.2",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.7",
@@ -19041,7 +19041,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.24.0-nightly.20251227.37be16243",
"version": "0.24.0-preview.2",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^5.3.3"
@@ -19052,7 +19052,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.24.0-nightly.20251227.37be16243",
"version": "0.24.0-preview.2",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.24.0-nightly.20251227.37be16243",
"version": "0.24.0-preview.2",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.24.0-nightly.20251227.37be16243"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.24.0-preview.2"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -62,7 +62,7 @@
"pre-commit": "node scripts/pre-commit.js"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.4.6",
"ink": "npm:@jrichman/ink@6.4.7",
"wrap-ansi": "9.0.2",
"cliui": {
"wrap-ansi": "7.0.0"
@@ -121,7 +121,7 @@
"yargs": "^17.7.2"
},
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.6",
"ink": "npm:@jrichman/ink@6.4.7",
"latest-version": "^9.0.0",
"simple-git": "^3.28.0"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.24.0-nightly.20251227.37be16243",
"version": "0.24.0-preview.2",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.24.0-nightly.20251227.37be16243",
"version": "0.24.0-preview.2",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.24.0-nightly.20251227.37be16243"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.24.0-preview.2"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.11.0",
@@ -45,7 +45,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.6",
"ink": "npm:@jrichman/ink@6.4.7",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -0,0 +1,231 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { settingsCommand } from './settings.js';
import yargs from 'yargs';
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
import type { getExtensionAndManager } from './utils.js';
import type {
updateSetting,
getScopedEnvContents,
} from '../../config/extensions/extensionSettings.js';
import {
promptForSetting,
ExtensionSettingScope,
} from '../../config/extensions/extensionSettings.js';
import type { exitCli } from '../utils.js';
import type { ExtensionManager } from '../../config/extension-manager.js';
const mockGetExtensionAndManager: Mock<typeof getExtensionAndManager> =
vi.hoisted(() => vi.fn());
const mockUpdateSetting: Mock<typeof updateSetting> = vi.hoisted(() => vi.fn());
const mockGetScopedEnvContents: Mock<typeof getScopedEnvContents> = vi.hoisted(
() => vi.fn(),
);
const mockExitCli: Mock<typeof exitCli> = vi.hoisted(() => vi.fn());
vi.mock('./utils.js', () => ({
getExtensionAndManager: mockGetExtensionAndManager,
}));
vi.mock('../../config/extensions/extensionSettings.js', () => ({
updateSetting: mockUpdateSetting,
promptForSetting: vi.fn(),
ExtensionSettingScope: {
USER: 'user',
WORKSPACE: 'workspace',
},
getScopedEnvContents: mockGetScopedEnvContents,
}));
vi.mock('@google/gemini-cli-core', () => ({
debugLogger: {
log: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../utils.js', () => ({
exitCli: mockExitCli,
}));
describe('settings command', () => {
let debugLogSpy: Mock;
let debugErrorSpy: Mock;
beforeEach(() => {
debugLogSpy = debugLogger.log as Mock;
debugErrorSpy = debugLogger.error as Mock;
});
afterEach(() => {
vi.clearAllMocks();
});
describe('set command', () => {
it('should log error and exit if extension is not found', async () => {
mockGetExtensionAndManager.mockResolvedValue({
extension: null,
extensionManager: null,
});
await yargs([])
.command(settingsCommand)
.parseAsync('settings set foo bar');
expect(mockExitCli).toHaveBeenCalled();
});
it('should log error and exit if extension config is not found', async () => {
const mockExtensionManager = {
loadExtensionConfig: vi.fn().mockResolvedValue(null),
} as unknown as ExtensionManager;
mockGetExtensionAndManager.mockResolvedValue({
extension: { path: '/path/to/ext' } as unknown as GeminiCLIExtension,
extensionManager: mockExtensionManager,
});
await yargs([])
.command(settingsCommand)
.parseAsync('settings set foo bar');
expect(debugErrorSpy).toHaveBeenCalledWith(
'Could not find configuration for extension "foo".',
);
expect(mockExitCli).toHaveBeenCalled();
});
it('should call updateSetting with correct arguments', async () => {
const mockExtensionManager = {
loadExtensionConfig: vi.fn().mockResolvedValue({}),
} as unknown as ExtensionManager;
const extension = { path: '/path/to/ext', id: 'ext-id' };
mockGetExtensionAndManager.mockResolvedValue({
extension: extension as unknown as GeminiCLIExtension,
extensionManager: mockExtensionManager,
});
await yargs([])
.command(settingsCommand)
.parseAsync('settings set foo bar --scope workspace');
expect(mockUpdateSetting).toHaveBeenCalledWith(
{},
'ext-id',
'bar',
promptForSetting,
ExtensionSettingScope.WORKSPACE,
);
expect(mockExitCli).toHaveBeenCalled();
});
});
describe('list command', () => {
it('should log error and exit if extension is not found', async () => {
mockGetExtensionAndManager.mockResolvedValue({
extension: null,
extensionManager: null,
});
await yargs([]).command(settingsCommand).parseAsync('settings list foo');
expect(mockExitCli).toHaveBeenCalled();
});
it('should log message and exit if extension has no settings', async () => {
const mockExtensionManager = {
loadExtensionConfig: vi.fn().mockResolvedValue({ settings: [] }),
} as unknown as ExtensionManager;
mockGetExtensionAndManager.mockResolvedValue({
extension: { path: '/path/to/ext' } as unknown as GeminiCLIExtension,
extensionManager: mockExtensionManager,
});
await yargs([]).command(settingsCommand).parseAsync('settings list foo');
expect(debugLogSpy).toHaveBeenCalledWith(
'Extension "foo" has no settings to configure.',
);
expect(mockExitCli).toHaveBeenCalled();
});
it('should list settings correctly', async () => {
const mockExtensionManager = {
loadExtensionConfig: vi.fn().mockResolvedValue({
settings: [
{
name: 'Setting 1',
envVar: 'SETTING_1',
description: 'Desc 1',
sensitive: false,
},
{
name: 'Setting 2',
envVar: 'SETTING_2',
description: 'Desc 2',
sensitive: true,
},
{
name: 'Setting 3',
envVar: 'SETTING_3',
description: 'Desc 3',
sensitive: false,
},
],
}),
} as unknown as ExtensionManager;
const extension = { path: '/path/to/ext', id: 'ext-id' };
mockGetExtensionAndManager.mockResolvedValue({
extension: extension as unknown as GeminiCLIExtension,
extensionManager: mockExtensionManager,
});
mockGetScopedEnvContents.mockImplementation((_config, _id, scope) => {
if (scope === ExtensionSettingScope.USER) {
return Promise.resolve({
SETTING_1: 'val1',
SETTING_2: 'val2',
});
}
if (scope === ExtensionSettingScope.WORKSPACE) {
return Promise.resolve({
SETTING_3: 'val3',
});
}
return Promise.resolve({});
});
await yargs([]).command(settingsCommand).parseAsync('settings list foo');
expect(debugLogSpy).toHaveBeenCalledWith('Settings for "foo":');
// Setting 1 (User)
expect(debugLogSpy).toHaveBeenCalledWith('\n- Setting 1 (SETTING_1)');
expect(debugLogSpy).toHaveBeenCalledWith(' Description: Desc 1');
expect(debugLogSpy).toHaveBeenCalledWith(' Value: val1 (user)');
// Setting 2 (Sensitive)
expect(debugLogSpy).toHaveBeenCalledWith('\n- Setting 2 (SETTING_2)');
expect(debugLogSpy).toHaveBeenCalledWith(' Description: Desc 2');
expect(debugLogSpy).toHaveBeenCalledWith(
' Value: [value stored in keychain] (user)',
);
// Setting 3 (Workspace)
expect(debugLogSpy).toHaveBeenCalledWith('\n- Setting 3 (SETTING_3)');
expect(debugLogSpy).toHaveBeenCalledWith(' Description: Desc 3');
expect(debugLogSpy).toHaveBeenCalledWith(' Value: val3 (workspace)');
expect(mockExitCli).toHaveBeenCalled();
});
});
});
@@ -47,6 +47,7 @@ const setCommand: CommandModule<object, SetArgs> = {
const { name, setting, scope } = args;
const { extension, extensionManager } = await getExtensionAndManager(name);
if (!extension || !extensionManager) {
await exitCli();
return;
}
const extensionConfig = await extensionManager.loadExtensionConfig(
@@ -56,6 +57,7 @@ const setCommand: CommandModule<object, SetArgs> = {
debugLogger.error(
`Could not find configuration for extension "${name}".`,
);
await exitCli();
return;
}
await updateSetting(
@@ -87,6 +89,7 @@ const listCommand: CommandModule<object, ListArgs> = {
const { name } = args;
const { extension, extensionManager } = await getExtensionAndManager(name);
if (!extension || !extensionManager) {
await exitCli();
return;
}
const extensionConfig = await extensionManager.loadExtensionConfig(
@@ -98,6 +101,7 @@ const listCommand: CommandModule<object, ListArgs> = {
extensionConfig.settings.length === 0
) {
debugLogger.log(`Extension "${name}" has no settings to configure.`);
await exitCli();
return;
}
@@ -512,7 +512,7 @@ describe('migrate command', () => {
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
);
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
'Note: Set tools.enableHooks to true in your settings to enable the hook system.',
'Note: Set hooks.enabled to true in your settings to enable the hook system.',
);
});
});
+1 -1
View File
@@ -243,7 +243,7 @@ export async function handleMigrateFromClaude() {
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
);
debugLogger.log(
'Note: Set tools.enableHooks to true in your settings to enable the hook system.',
'Note: Set hooks.enabled to true in your settings to enable the hook system.',
);
} catch (error) {
debugLogger.error(`Error saving migrated hooks: ${getErrorMessage(error)}`);
+132 -1
View File
@@ -1069,7 +1069,7 @@ describe('Approval mode tool exclusion logic', () => {
};
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
'Cannot start in YOLO mode when it is disabled by settings',
'Cannot start in YOLO mode since it is disabled by your admin',
);
});
@@ -2412,3 +2412,134 @@ describe('Policy Engine Integration in loadCliConfig', () => {
);
});
});
describe('loadCliConfig secureModeEnabled', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: undefined,
});
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('should throw an error if YOLO mode is attempted when secureModeEnabled is true', async () => {
process.argv = ['node', 'script.js', '--yolo'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
admin: {
secureModeEnabled: true,
},
};
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
'Cannot start in YOLO mode since it is disabled by your admin',
);
});
it('should throw an error if approval-mode=yolo is attempted when secureModeEnabled is true', async () => {
process.argv = ['node', 'script.js', '--approval-mode=yolo'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
admin: {
secureModeEnabled: true,
},
};
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
'Cannot start in YOLO mode since it is disabled by your admin',
);
});
it('should set disableYoloMode to true when secureModeEnabled is true', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
admin: {
secureModeEnabled: true,
},
};
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.isYoloModeDisabled()).toBe(true);
});
});
describe('loadCliConfig mcpEnabled', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
const mcpSettings = {
mcp: {
serverCommand: 'mcp-server',
allowed: ['serverA'],
excluded: ['serverB'],
},
mcpServers: { serverA: { url: 'http://a' } },
};
it('should enable MCP by default', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { ...mcpSettings };
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getMcpEnabled()).toBe(true);
expect(config.getMcpServerCommand()).toBe('mcp-server');
expect(config.getMcpServers()).toEqual({ serverA: { url: 'http://a' } });
expect(config.getAllowedMcpServers()).toEqual(['serverA']);
expect(config.getBlockedMcpServers()).toEqual(['serverB']);
});
it('should disable MCP when mcpEnabled is false', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
...mcpSettings,
admin: {
mcp: {
enabled: false,
},
},
};
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getMcpEnabled()).toBe(false);
expect(config.getMcpServerCommand()).toBeUndefined();
expect(config.getMcpServers()).toEqual({});
expect(config.getAllowedMcpServers()).toEqual([]);
expect(config.getBlockedMcpServers()).toEqual([]);
});
it('should enable MCP when mcpEnabled is true', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
...mcpSettings,
admin: {
mcp: {
enabled: true,
},
},
};
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getMcpEnabled()).toBe(true);
expect(config.getMcpServerCommand()).toBe('mcp-server');
expect(config.getMcpServers()).toEqual({ serverA: { url: 'http://a' } });
expect(config.getAllowedMcpServers()).toEqual(['serverA']);
expect(config.getBlockedMcpServers()).toEqual(['serverB']);
});
});
+36 -13
View File
@@ -53,6 +53,7 @@ import { requestConsentNonInteractive } from './extensions/consent.js';
import { promptForSetting } from './extensions/extensionSettings.js';
import type { EventEmitter } from 'node:stream';
import { runExitCleanup } from '../utils/cleanup.js';
import { getEnableHooks } from './settingsSchema.js';
export interface CliArgs {
query: string | undefined;
@@ -291,7 +292,7 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
}
// Register hooks command if hooks are enabled
if (settings?.tools?.enableHooks) {
if (getEnableHooks(settings)) {
yargsInstance.command(hooksCommand);
}
@@ -413,7 +414,7 @@ export async function loadCliConfig(
const ideMode = settings.ide?.enabled ?? false;
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? true;
const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? false;
// Set the context filename in the server's memoryTool module BEFORE loading memory
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
@@ -504,11 +505,19 @@ export async function loadCliConfig(
}
// Override approval mode if disableYoloMode is set.
if (settings.security?.disableYoloMode) {
if (settings.security?.disableYoloMode || settings.admin?.secureModeEnabled) {
if (approvalMode === ApprovalMode.YOLO) {
debugLogger.error('YOLO mode is disabled by the "disableYolo" setting.');
if (settings.admin?.secureModeEnabled) {
debugLogger.error(
'YOLO mode is disabled by "secureModeEnabled" setting.',
);
} else {
debugLogger.error(
'YOLO mode is disabled by the "disableYolo" setting.',
);
}
throw new FatalConfigError(
'Cannot start in YOLO mode when it is disabled by settings',
'Cannot start in YOLO mode since it is disabled by your admin',
);
}
approvalMode = ApprovalMode.DEFAULT;
@@ -627,6 +636,8 @@ export async function loadCliConfig(
const ptyInfo = await getPty();
const mcpEnabled = settings.admin?.mcp?.enabled ?? true;
return new Config({
sessionId,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
@@ -645,12 +656,17 @@ export async function loadCliConfig(
excludeTools,
toolDiscoveryCommand: settings.tools?.discoveryCommand,
toolCallCommand: settings.tools?.callCommand,
mcpServerCommand: settings.mcp?.serverCommand,
mcpServers: settings.mcpServers,
allowedMcpServers: argv.allowedMcpServerNames ?? settings.mcp?.allowed,
blockedMcpServers: argv.allowedMcpServerNames
? undefined
: settings.mcp?.excluded,
mcpServerCommand: mcpEnabled ? settings.mcp?.serverCommand : undefined,
mcpServers: mcpEnabled ? settings.mcpServers : {},
mcpEnabled,
allowedMcpServers: mcpEnabled
? (argv.allowedMcpServerNames ?? settings.mcp?.allowed)
: undefined,
blockedMcpServers: mcpEnabled
? argv.allowedMcpServerNames
? undefined
: settings.mcp?.excluded
: undefined,
blockedEnvironmentVariables:
settings.security?.environmentVariableRedaction?.blocked,
enableEnvironmentVariableRedaction:
@@ -659,7 +675,8 @@ export async function loadCliConfig(
geminiMdFileCount: fileCount,
geminiMdFilePaths: filePaths,
approvalMode,
disableYoloMode: settings.security?.disableYoloMode,
disableYoloMode:
settings.security?.disableYoloMode || settings.admin?.secureModeEnabled,
showMemoryUsage: settings.ui?.showMemoryUsage || false,
accessibility: {
...settings.ui?.accessibility,
@@ -722,10 +739,16 @@ export async function loadCliConfig(
ptyInfo: ptyInfo?.name,
modelConfigServiceConfig: settings.modelConfigs,
// TODO: loading of hooks based on workspace trust
enableHooks: settings.tools?.enableHooks,
enableHooks: getEnableHooks(settings),
hooks: settings.hooks || {},
projectHooks: projectHooks || {},
onModelChange: (model: string) => saveModelChange(loadedSettings, model),
onReload: async () => {
const refreshedSettings = loadSettings(cwd);
return {
disabledSkills: refreshedSettings.merged.skills?.disabled,
};
},
});
}
+18 -20
View File
@@ -46,6 +46,7 @@ import {
type HookDefinition,
type HookEventName,
type ResolvedExtensionSetting,
coreEvents,
} from '@google/gemini-cli-core';
import { maybeRequestConsentOrFail } from './extensions/consent.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
@@ -59,9 +60,11 @@ import {
import {
getEnvContents,
maybePromptForSettings,
getMissingSettings,
type ExtensionSetting,
} from './extensions/extensionSettings.js';
import type { EventEmitter } from 'node:stream';
import { getEnableHooks } from './settingsSchema.js';
interface ExtensionManagerParams {
enabledExtensionOverrides?: string[];
@@ -227,25 +230,6 @@ Would you like to attempt to install via "git clone" instead?`,
try {
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
if (isUpdate && installMetadata.autoUpdate) {
const oldSettings = new Set(
previousExtensionConfig.settings?.map((s) => s.name) || [],
);
const newSettings = new Set(
newExtensionConfig.settings?.map((s) => s.name) || [],
);
const settingsAreEqual =
oldSettings.size === newSettings.size &&
[...oldSettings].every((value) => newSettings.has(value));
if (!settingsAreEqual && installMetadata.autoUpdate) {
throw new Error(
`Extension "${newExtensionConfig.name}" has settings changes and cannot be auto-updated. Please update manually.`,
);
}
}
const newExtensionName = newExtensionConfig.name;
const previous = this.getExtensions().find(
(installed) => installed.name === newExtensionName,
@@ -316,6 +300,20 @@ Would you like to attempt to install via "git clone" instead?`,
}
}
const missingSettings = await getMissingSettings(
newExtensionConfig,
extensionId,
);
if (missingSettings.length > 0) {
const message = `Extension "${newExtensionConfig.name}" has missing settings: ${missingSettings
.map((s) => s.name)
.join(
', ',
)}. Please run "gemini extensions settings ${newExtensionConfig.name} <setting-name>" to configure them.`;
debugLogger.warn(message);
coreEvents.emitFeedback('warning', message);
}
if (
installMetadata.type === 'local' ||
installMetadata.type === 'git' ||
@@ -554,7 +552,7 @@ Would you like to attempt to install via "git clone" instead?`,
.filter((contextFilePath) => fs.existsSync(contextFilePath));
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
if (this.settings.tools?.enableHooks) {
if (getEnableHooks(this.settings)) {
hooks = await this.loadExtensionHooks(effectiveExtensionPath, {
extensionPath: effectiveExtensionPath,
workspacePath: this.workspaceDir,
+17 -14
View File
@@ -750,8 +750,8 @@ describe('extension tests', () => {
);
const settings = loadSettings(tempWorkspaceDir).merged;
if (!settings.tools) settings.tools = {};
settings.tools.enableHooks = true;
if (!settings.hooks) settings.hooks = {};
settings.hooks.enabled = true;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
@@ -771,7 +771,7 @@ describe('extension tests', () => {
);
});
it('should not load hooks if enableHooks is false', async () => {
it('should not load hooks if hooks.enabled is false', async () => {
const extDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'hook-extension-disabled',
@@ -786,8 +786,8 @@ describe('extension tests', () => {
);
const settings = loadSettings(tempWorkspaceDir).merged;
if (!settings.tools) settings.tools = {};
settings.tools.enableHooks = false;
if (!settings.hooks) settings.hooks = {};
settings.hooks.enabled = false;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
@@ -1447,7 +1447,7 @@ This extension will run the following MCP servers:
expect(envContent).toContain('NEW_SETTING=new-setting-value');
});
it('should fail auto-update if settings have changed', async () => {
it('should auto-update if settings have changed', async () => {
// 1. Install initial version with autoUpdate: true
const oldSourceExtDir = createExtension({
extensionsDir: tempHomeDir,
@@ -1469,7 +1469,7 @@ This extension will run the following MCP servers:
});
// 2. Create new version with different settings
const newSourceExtDir = createExtension({
const extensionDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-auto-update-ext',
version: '1.1.0',
@@ -1488,14 +1488,17 @@ This extension will run the following MCP servers:
);
// 3. Attempt to update and assert it fails
await expect(
extensionManager.installOrUpdateExtension(
{ source: newSourceExtDir, type: 'local', autoUpdate: true },
previousExtensionConfig,
),
).rejects.toThrow(
'Extension "my-auto-update-ext" has settings changes and cannot be auto-updated. Please update manually.',
const updatedExtension = await extensionManager.installOrUpdateExtension(
{
source: extensionDir,
type: 'local',
autoUpdate: true,
},
previousExtensionConfig,
);
expect(updatedExtension.version).toBe('1.1.0');
expect(extensionManager.getExtensions()[0].version).toBe('1.1.0');
});
it('should throw an error for invalid extension names', async () => {
@@ -298,3 +298,24 @@ async function clearSettings(
}
return;
}
export async function getMissingSettings(
extensionConfig: ExtensionConfig,
extensionId: string,
): Promise<ExtensionSetting[]> {
const { settings } = extensionConfig;
if (!settings || settings.length === 0) {
return [];
}
const existingSettings = await getEnvContents(extensionConfig, extensionId);
const missingSettings: ExtensionSetting[] = [];
for (const setting of settings) {
if (existingSettings[setting.envVar] === undefined) {
missingSettings.push(setting);
}
}
return missingSettings;
}
@@ -0,0 +1,308 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as path from 'node:path';
import * as os from 'node:os';
import * as fs from 'node:fs';
import { getMissingSettings } from './extensionSettings.js';
import type { ExtensionConfig } from '../extension.js';
import { ExtensionStorage } from './storage.js';
import {
KeychainTokenStorage,
debugLogger,
type ExtensionInstallMetadata,
type GeminiCLIExtension,
coreEvents,
} from '@google/gemini-cli-core';
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
import { ExtensionManager } from '../extension-manager.js';
vi.mock('node:fs', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actual = await importOriginal<any>();
return {
...actual,
default: {
...actual.default,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)),
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
KeychainTokenStorage: vi.fn(),
debugLogger: {
warn: vi.fn(),
error: vi.fn(),
log: vi.fn(),
},
coreEvents: {
emitFeedback: vi.fn(), // Mock emitFeedback
on: vi.fn(),
off: vi.fn(),
},
};
});
// Mock os.homedir because ExtensionStorage uses it
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
return {
...mockedOs,
homedir: vi.fn(),
};
});
describe('extensionUpdates', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
let extensionDir: string;
let mockKeychainData: Record<string, Record<string, string>>;
beforeEach(() => {
vi.clearAllMocks();
mockKeychainData = {};
// Mock Keychain
vi.mocked(KeychainTokenStorage).mockImplementation(
(serviceName: string) => {
if (!mockKeychainData[serviceName]) {
mockKeychainData[serviceName] = {};
}
const keychainData = mockKeychainData[serviceName];
return {
getSecret: vi
.fn()
.mockImplementation(
async (key: string) => keychainData[key] || null,
),
setSecret: vi
.fn()
.mockImplementation(async (key: string, value: string) => {
keychainData[key] = value;
}),
deleteSecret: vi.fn().mockImplementation(async (key: string) => {
delete keychainData[key];
}),
listSecrets: vi
.fn()
.mockImplementation(async () => Object.keys(keychainData)),
isAvailable: vi.fn().mockResolvedValue(true),
} as unknown as KeychainTokenStorage;
},
);
// Setup Temp Dirs
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext');
// Mock ExtensionStorage to rely on our temp extension dir
vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue(
extensionDir,
);
// Mock getEnvFilePath is checking extensionDir/variables.env? No, it used ExtensionStorage logic.
// getEnvFilePath in extensionSettings.ts:
// if workspace, process.cwd()/.env (we need to mock process.cwd or move tempWorkspaceDir there)
// if user, ExtensionStorage(name).getEnvFilePath() -> joins extensionDir + '.env'
fs.mkdirSync(extensionDir, { recursive: true });
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe('getMissingSettings', () => {
it('should return empty list if all settings are present', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's1', description: 'd1', envVar: 'VAR1' },
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
],
};
const extensionId = '12345';
// Setup User Env
const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
fs.writeFileSync(userEnvPath, 'VAR1=val1');
// Setup Keychain
const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext ${extensionId}`,
);
await userKeychain.setSecret('VAR2', 'val2');
const missing = await getMissingSettings(config, extensionId);
expect(missing).toEqual([]);
});
it('should identify missing non-sensitive settings', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
const extensionId = '12345';
const missing = await getMissingSettings(config, extensionId);
expect(missing).toHaveLength(1);
expect(missing[0].name).toBe('s1');
});
it('should identify missing sensitive settings', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
],
};
const extensionId = '12345';
const missing = await getMissingSettings(config, extensionId);
expect(missing).toHaveLength(1);
expect(missing[0].name).toBe('s2');
});
it('should respect settings present in workspace', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
const extensionId = '12345';
// Setup Workspace Env
const workspaceEnvPath = path.join(
tempWorkspaceDir,
EXTENSION_SETTINGS_FILENAME,
);
fs.writeFileSync(workspaceEnvPath, 'VAR1=val1');
const missing = await getMissingSettings(config, extensionId);
expect(missing).toEqual([]);
});
});
describe('ExtensionManager integration', () => {
it('should warn about missing settings after update', async () => {
// Mock ExtensionManager methods to avoid FS/Network usage
const newConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.1.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
const previousConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [],
};
const installMetadata: ExtensionInstallMetadata = {
source: extensionDir,
type: 'local',
autoUpdate: true,
};
const manager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
settings: { telemetry: {} } as any,
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: null, // Simulate non-interactive
});
// Mock methods called by installOrUpdateExtension
vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig);
vi.spyOn(manager, 'getExtensions').mockReturnValue([
{
name: 'test-ext',
version: '1.0.0',
installMetadata,
path: extensionDir,
// Mocks for other required props
contextFiles: [],
mcpServers: {},
hooks: undefined,
isActive: true,
id: 'test-id',
settings: [],
resolvedSettings: [],
skills: [],
} as unknown as GeminiCLIExtension,
]);
vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(manager as any, 'loadExtension').mockResolvedValue(
{} as unknown as GeminiCLIExtension,
);
vi.spyOn(manager, 'enableExtension').mockResolvedValue(undefined);
// Mock fs.promises for the operations inside installOrUpdateExtension
vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
vi.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
vi.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
vi.mocked(fs.existsSync).mockReturnValue(false); // No hooks
// Mock copyExtension? It's imported.
// We can rely on ignoring the failure if we mock enough.
// Actually copyExtension is called. We need to mock it if it does real IO.
// But we can just let it fail or mock fs.cp if it uses it.
// Let's assume the other mocks cover the critical path to the warning.
// Warning happens BEFORE copyExtension?
// No, warning is after copyExtension usually.
// But in my code:
// const missingSettings = await getMissingSettings(...)
// if (missingSettings.length > 0) debugLogger.warn(...)
// ...
// copyExtension(...)
// Wait, let's check extension-manager.ts order.
// Line 303: getMissingSettings
// Line 317: if (installMetadata.type === 'local' ...) copyExtension
// So getMissingSettings is called BEFORE copyExtension.
try {
await manager.installOrUpdateExtension(installMetadata, previousConfig);
} catch (_) {
// Ignore errors from copyExtension or others, we just want to verify the warning
}
expect(debugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'Extension "test-ext" has missing settings: s1',
),
);
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining(
'Please run "gemini extensions settings test-ext <setting-name>"',
),
);
});
});
});
+2 -817
View File
@@ -44,7 +44,7 @@ vi.mock('./settingsSchema.js', async (importOriginal) => {
});
// NOW import everything else, including the (now effectively re-exported) settings.js
import path, * as pathActual from 'node:path'; // Restored for MOCK_WORKSPACE_SETTINGS_PATH
import * as pathActual from 'node:path'; // Restored for MOCK_WORKSPACE_SETTINGS_PATH
import {
describe,
it,
@@ -65,18 +65,12 @@ import {
USER_SETTINGS_PATH, // This IS the mocked path.
getSystemSettingsPath,
getSystemDefaultsPath,
migrateSettingsToV1,
needsMigration,
type Settings,
loadEnvironment,
migrateDeprecatedSettings,
SettingScope,
saveSettings,
type SettingsFile,
getDefaultsFromSchema,
} from './settings.js';
import { FatalConfigError, GEMINI_DIR } from '@google/gemini-cli-core';
import { ExtensionManager } from './extension-manager.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
import {
getSettingsSchema,
@@ -114,6 +108,7 @@ vi.mock('./extension.js');
const mockCoreEvents = vi.hoisted(() => ({
emitFeedback: vi.fn(),
emitSettingsChanged: vi.fn(),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
@@ -289,169 +284,6 @@ describe('Settings Loading and Merging', () => {
});
});
it('should correctly migrate a complex legacy (v1) settings file', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const legacySettingsContent = {
theme: 'legacy-dark',
vimMode: true,
contextFileName: 'LEGACY_CONTEXT.md',
model: 'gemini-2.5-pro',
mcpServers: {
'legacy-server-1': {
command: 'npm',
args: ['run', 'start:server1'],
description: 'Legacy Server 1',
},
'legacy-server-2': {
command: 'node',
args: ['server2.js'],
description: 'Legacy Server 2',
},
},
allowMCPServers: ['legacy-server-1'],
someUnrecognizedSetting: 'should-be-preserved',
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(legacySettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged).toMatchObject({
ui: {
theme: 'legacy-dark',
},
general: {
vimMode: true,
},
context: {
fileName: 'LEGACY_CONTEXT.md',
},
model: {
name: 'gemini-2.5-pro',
},
mcpServers: {
'legacy-server-1': {
command: 'npm',
args: ['run', 'start:server1'],
description: 'Legacy Server 1',
},
'legacy-server-2': {
command: 'node',
args: ['server2.js'],
description: 'Legacy Server 2',
},
},
mcp: {
allowed: ['legacy-server-1'],
},
someUnrecognizedSetting: 'should-be-preserved',
});
});
it('should rewrite allowedTools to tools.allowed during migration', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const legacySettingsContent = {
allowedTools: ['fs', 'shell'],
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(legacySettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.tools?.allowed).toEqual(['fs', 'shell']);
expect((settings.merged as TestSettings)['allowedTools']).toBeUndefined();
});
it('should allow V2 settings to override V1 settings when both are present (zombie setting fix)', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const mixedSettingsContent = {
// V1 setting (migrates to ui.accessibility.screenReader = true)
accessibility: {
screenReader: true,
},
// V2 setting (explicitly set to false)
ui: {
accessibility: {
screenReader: false,
},
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(mixedSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// We expect the V2 setting (false) to win, NOT the migrated V1 setting (true)
expect(settings.merged.ui?.accessibility?.screenReader).toBe(false);
});
it('should correctly merge and migrate legacy array properties from multiple scopes', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const legacyUserSettings = {
includeDirectories: ['/user/dir'],
excludeTools: ['user-tool'],
excludedProjectEnvVars: ['USER_VAR'],
};
const legacyWorkspaceSettings = {
includeDirectories: ['/workspace/dir'],
excludeTools: ['workspace-tool'],
excludedProjectEnvVars: ['WORKSPACE_VAR', 'USER_VAR'],
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(legacyUserSettings);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(legacyWorkspaceSettings);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Verify includeDirectories are concatenated
expect(settings.merged.context?.includeDirectories).toEqual([
'/user/dir',
'/workspace/dir',
]);
// Verify excludeTools are concatenated and de-duped
expect(settings.merged.tools?.exclude).toEqual([
'user-tool',
'workspace-tool',
]);
// Verify excludedProjectEnvVars are concatenated and de-duped
expect(settings.merged.advanced?.excludedEnvVars).toEqual(
expect.arrayContaining(['USER_VAR', 'WORKSPACE_VAR']),
);
expect(settings.merged.advanced?.excludedEnvVars).toHaveLength(4);
});
it('should merge all settings files with the correct precedence', () => {
// Mock schema to test defaults application
const mockSchema = {
@@ -1770,653 +1602,6 @@ describe('Settings Loading and Merging', () => {
});
});
describe('with workspace trust', () => {
it('should merge workspace settings when workspace is trusted', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
ui: { theme: 'dark' },
tools: { sandbox: false },
};
const workspaceSettingsContent = {
tools: { sandbox: true },
context: { fileName: 'WORKSPACE.md' },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.tools?.sandbox).toBe(true);
expect(settings.merged.context?.fileName).toBe('WORKSPACE.md');
expect(settings.merged.ui?.theme).toBe('dark');
});
it('should NOT merge workspace settings when workspace is not trusted', () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
ui: { theme: 'dark' },
tools: { sandbox: false },
context: { fileName: 'USER.md' },
};
const workspaceSettingsContent = {
tools: { sandbox: true },
context: { fileName: 'WORKSPACE.md' },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.tools?.sandbox).toBe(false); // User setting
expect(settings.merged.context?.fileName).toBe('USER.md'); // User setting
expect(settings.merged.ui?.theme).toBe('dark'); // User setting
});
});
describe('migrateSettingsToV1', () => {
it('should handle an empty object', () => {
const v2Settings = {};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({});
});
it('should migrate a simple v2 settings object to v1', () => {
const v2Settings = {
general: {
preferredEditor: 'vscode',
vimMode: true,
},
ui: {
theme: 'dark',
},
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
preferredEditor: 'vscode',
vimMode: true,
theme: 'dark',
});
});
it('should handle nested properties correctly', () => {
const v2Settings = {
security: {
folderTrust: {
enabled: true,
},
auth: {
selectedType: 'oauth',
},
},
advanced: {
autoConfigureMemory: true,
},
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
folderTrust: true,
selectedAuthType: 'oauth',
autoConfigureMaxOldSpaceSize: true,
});
});
it('should preserve mcpServers at the top level', () => {
const v2Settings = {
general: {
preferredEditor: 'vscode',
},
mcpServers: {
'my-server': {
command: 'npm start',
},
},
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
preferredEditor: 'vscode',
mcpServers: {
'my-server': {
command: 'npm start',
},
},
});
});
it('should carry over unrecognized top-level properties', () => {
const v2Settings = {
general: {
vimMode: false,
},
unrecognized: 'value',
another: {
nested: true,
},
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
vimMode: false,
unrecognized: 'value',
another: {
nested: true,
},
});
});
it('should handle a complex object with mixed properties', () => {
const v2Settings = {
general: {
disableAutoUpdate: true,
},
ui: {
hideBanner: true,
customThemes: {
myTheme: {},
},
},
model: {
name: 'gemini-pro',
},
mcpServers: {
'server-1': {
command: 'node server.js',
},
},
unrecognized: {
should: 'be-preserved',
},
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
disableAutoUpdate: true,
hideBanner: true,
customThemes: {
myTheme: {},
},
model: 'gemini-pro',
mcpServers: {
'server-1': {
command: 'node server.js',
},
},
unrecognized: {
should: 'be-preserved',
},
});
});
it('should not migrate a v1 settings object', () => {
const v1Settings = {
preferredEditor: 'vscode',
vimMode: true,
theme: 'dark',
};
const migratedSettings = migrateSettingsToV1(v1Settings);
expect(migratedSettings).toEqual({
preferredEditor: 'vscode',
vimMode: true,
theme: 'dark',
});
});
it('should migrate a full v2 settings object to v1', () => {
const v2Settings: TestSettings = {
general: {
preferredEditor: 'code',
vimMode: true,
},
ui: {
theme: 'dark',
},
privacy: {
usageStatisticsEnabled: false,
},
model: {
name: 'gemini-2.5-pro',
},
context: {
fileName: 'CONTEXT.md',
includeDirectories: ['/src'],
},
tools: {
sandbox: true,
exclude: ['toolA'],
},
mcp: {
allowed: ['server1'],
},
security: {
folderTrust: {
enabled: true,
},
},
advanced: {
dnsResolutionOrder: 'ipv4first',
excludedEnvVars: ['SECRET'],
},
mcpServers: {
'my-server': {
command: 'npm start',
},
},
unrecognizedTopLevel: {
value: 'should be preserved',
},
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
preferredEditor: 'code',
vimMode: true,
theme: 'dark',
usageStatisticsEnabled: false,
model: 'gemini-2.5-pro',
contextFileName: 'CONTEXT.md',
includeDirectories: ['/src'],
sandbox: true,
excludeTools: ['toolA'],
allowMCPServers: ['server1'],
folderTrust: true,
dnsResolutionOrder: 'ipv4first',
excludedProjectEnvVars: ['SECRET'],
mcpServers: {
'my-server': {
command: 'npm start',
},
},
unrecognizedTopLevel: {
value: 'should be preserved',
},
});
});
it('should handle partial v2 settings', () => {
const v2Settings: TestSettings = {
general: {
vimMode: false,
},
ui: {},
model: {
name: 'gemini-2.5-pro',
},
unrecognized: 'value',
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
vimMode: false,
model: 'gemini-2.5-pro',
unrecognized: 'value',
});
});
it('should handle settings with different data types', () => {
const v2Settings: TestSettings = {
general: {
vimMode: false,
},
model: {
maxSessionTurns: -1,
},
context: {
includeDirectories: [],
},
security: {
folderTrust: {
enabled: undefined,
},
},
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
vimMode: false,
maxSessionTurns: -1,
includeDirectories: [],
security: {
folderTrust: {
enabled: undefined,
},
},
});
});
it('should preserve unrecognized top-level keys', () => {
const v2Settings: TestSettings = {
general: {
vimMode: true,
},
customTopLevel: {
a: 1,
b: [2],
},
anotherOne: 'hello',
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
vimMode: true,
customTopLevel: {
a: 1,
b: [2],
},
anotherOne: 'hello',
});
});
it('should handle an empty v2 settings object', () => {
const v2Settings = {};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({});
});
it('should correctly handle mcpServers at the top level', () => {
const v2Settings: TestSettings = {
mcpServers: {
serverA: { command: 'a' },
},
mcp: {
allowed: ['serverA'],
},
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
mcpServers: {
serverA: { command: 'a' },
},
allowMCPServers: ['serverA'],
});
});
it('should correctly migrate customWittyPhrases', () => {
const v2Settings: Partial<Settings> = {
ui: {
customWittyPhrases: ['test phrase'],
},
};
const v1Settings = migrateSettingsToV1(v2Settings as Settings);
expect(v1Settings).toEqual({
customWittyPhrases: ['test phrase'],
});
});
});
describe('loadEnvironment', () => {
function setup({
isFolderTrustEnabled = true,
isWorkspaceTrustedValue = true,
}) {
delete process.env['TESTTEST']; // reset
const geminiEnvPath = path.resolve(path.join(GEMINI_DIR, '.env'));
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: isWorkspaceTrustedValue,
source: 'file',
});
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
[USER_SETTINGS_PATH, geminiEnvPath].includes(p.toString()),
);
const userSettingsContent: Settings = {
ui: {
theme: 'dark',
},
security: {
folderTrust: {
enabled: isFolderTrustEnabled,
},
},
context: {
fileName: 'USER_CONTEXT.md',
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === geminiEnvPath) return 'TESTTEST=1234';
return '{}';
},
);
}
it('sets environment variables from .env files', () => {
setup({ isFolderTrustEnabled: false, isWorkspaceTrustedValue: true });
loadEnvironment(loadSettings(MOCK_WORKSPACE_DIR).merged);
expect(process.env['TESTTEST']).toEqual('1234');
});
it('does not load env files from untrusted spaces', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
loadEnvironment(loadSettings(MOCK_WORKSPACE_DIR).merged);
expect(process.env['TESTTEST']).not.toEqual('1234');
});
});
describe('needsMigration', () => {
it('should return false for an empty object', () => {
expect(needsMigration({})).toBe(false);
});
it('should return false for settings that are already in V2 format', () => {
const v2Settings: Partial<Settings> = {
ui: {
theme: 'dark',
},
tools: {
sandbox: true,
},
};
expect(needsMigration(v2Settings)).toBe(false);
});
it('should return true for settings with a V1 key that needs to be moved', () => {
const v1Settings = {
theme: 'dark', // v1 key
};
expect(needsMigration(v1Settings)).toBe(true);
});
it('should return true for settings with a mix of V1 and V2 keys', () => {
const mixedSettings = {
theme: 'dark', // v1 key
tools: {
sandbox: true, // v2 key
},
};
expect(needsMigration(mixedSettings)).toBe(true);
});
it('should return false for settings with only V1 keys that are the same in V2', () => {
const v1Settings = {
mcpServers: {},
telemetry: {},
extensions: [],
};
expect(needsMigration(v1Settings)).toBe(false);
});
it('should return true for settings with a mix of V1 keys that are the same in V2 and V1 keys that need moving', () => {
const v1Settings = {
mcpServers: {}, // same in v2
theme: 'dark', // needs moving
};
expect(needsMigration(v1Settings)).toBe(true);
});
it('should return false for settings with unrecognized keys', () => {
const settings = {
someUnrecognizedKey: 'value',
};
expect(needsMigration(settings)).toBe(false);
});
it('should return false for settings with v2 keys and unrecognized keys', () => {
const settings = {
ui: { theme: 'dark' },
someUnrecognizedKey: 'value',
};
expect(needsMigration(settings)).toBe(false);
});
});
describe('migrateDeprecatedSettings', () => {
let mockFsExistsSync: Mock;
let mockFsReadFileSync: Mock;
beforeEach(() => {
vi.resetAllMocks();
mockFsExistsSync = vi.mocked(fs.existsSync);
mockFsExistsSync.mockReturnValue(true);
mockFsReadFileSync = vi.mocked(fs.readFileSync);
mockFsReadFileSync.mockReturnValue('{}');
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: undefined,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should migrate disabled extensions from user and workspace settings', () => {
const userSettingsContent = {
extensions: {
disabled: ['user-ext-1', 'shared-ext'],
},
};
const workspaceSettingsContent = {
extensions: {
disabled: ['workspace-ext-1', 'shared-ext'],
},
};
mockFsReadFileSync.mockImplementation((p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
});
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
const extensionManager = new ExtensionManager({
settings: loadedSettings.merged,
workspaceDir: MOCK_WORKSPACE_DIR,
requestConsent: vi.fn(),
requestSetting: vi.fn(),
});
const mockDisableExtension = vi.spyOn(
extensionManager,
'disableExtension',
);
mockDisableExtension.mockImplementation(async () => {});
migrateDeprecatedSettings(loadedSettings, extensionManager);
// Check user settings migration
expect(mockDisableExtension).toHaveBeenCalledWith(
'user-ext-1',
SettingScope.User,
);
expect(mockDisableExtension).toHaveBeenCalledWith(
'shared-ext',
SettingScope.User,
);
// Check workspace settings migration
expect(mockDisableExtension).toHaveBeenCalledWith(
'workspace-ext-1',
SettingScope.Workspace,
);
expect(mockDisableExtension).toHaveBeenCalledWith(
'shared-ext',
SettingScope.Workspace,
);
// Check that setValue was called to remove the deprecated setting
expect(setValueSpy).toHaveBeenCalledWith(
SettingScope.User,
'extensions',
{
disabled: undefined,
},
);
expect(setValueSpy).toHaveBeenCalledWith(
SettingScope.Workspace,
'extensions',
{
disabled: undefined,
},
);
});
it('should not do anything if there are no deprecated settings', () => {
const userSettingsContent = {
extensions: {
enabled: ['user-ext-1'],
},
};
const workspaceSettingsContent = {
someOtherSetting: 'value',
};
mockFsReadFileSync.mockImplementation((p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
});
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
const extensionManager = new ExtensionManager({
settings: loadedSettings.merged,
workspaceDir: MOCK_WORKSPACE_DIR,
requestConsent: vi.fn(),
requestSetting: vi.fn(),
});
const mockDisableExtension = vi.spyOn(
extensionManager,
'disableExtension',
);
mockDisableExtension.mockImplementation(async () => {});
migrateDeprecatedSettings(loadedSettings, extensionManager);
expect(mockDisableExtension).not.toHaveBeenCalled();
expect(setValueSpy).not.toHaveBeenCalled();
});
});
describe('saveSettings', () => {
it('should save settings using updateSettingsFilePreservingFormat', () => {
const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat);
+10 -310
View File
@@ -10,7 +10,6 @@ import { homedir, platform } from 'node:os';
import * as dotenv from 'dotenv';
import process from 'node:process';
import {
debugLogger,
FatalConfigError,
GEMINI_DIR,
getErrorMessage,
@@ -30,14 +29,12 @@ import {
getSettingsSchema,
} from './settingsSchema.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
import { customDeepMerge } from '../utils/deepMerge.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
import type { ExtensionManager } from './extension-manager.js';
import {
validateSettings,
formatValidationError,
} from './settings-validation.js';
import { SettingPaths } from './settingPaths.js';
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
let current: SettingDefinition | undefined = undefined;
@@ -66,79 +63,6 @@ export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
const MIGRATE_V2_OVERWRITE = true;
const MIGRATION_MAP: Record<string, string> = {
accessibility: 'ui.accessibility',
allowedTools: 'tools.allowed',
allowMCPServers: 'mcp.allowed',
autoAccept: 'tools.autoAccept',
autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory',
bugCommand: 'advanced.bugCommand',
chatCompression: 'model.compressionThreshold',
checkpointing: 'general.checkpointing',
coreTools: 'tools.core',
contextFileName: 'context.fileName',
customThemes: 'ui.customThemes',
customWittyPhrases: 'ui.customWittyPhrases',
debugKeystrokeLogging: 'general.debugKeystrokeLogging',
disableAutoUpdate: 'general.disableAutoUpdate',
disableUpdateNag: 'general.disableUpdateNag',
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
enableHooks: 'tools.enableHooks',
enablePromptCompletion: 'general.enablePromptCompletion',
enforcedAuthType: 'security.auth.enforcedType',
excludeTools: 'tools.exclude',
excludeMCPServers: 'mcp.excluded',
excludedProjectEnvVars: 'advanced.excludedEnvVars',
experimentalSkills: 'experimental.skills',
extensionManagement: 'experimental.extensionManagement',
extensions: 'extensions',
fileFiltering: 'context.fileFiltering',
folderTrustFeature: 'security.folderTrust.featureEnabled',
folderTrust: 'security.folderTrust.enabled',
hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge',
hideWindowTitle: 'ui.hideWindowTitle',
showStatusInTitle: 'ui.showStatusInTitle',
hideTips: 'ui.hideTips',
hideBanner: 'ui.hideBanner',
hideFooter: 'ui.hideFooter',
hideCWD: 'ui.footer.hideCWD',
hideSandboxStatus: 'ui.footer.hideSandboxStatus',
hideModelInfo: 'ui.footer.hideModelInfo',
hideContextSummary: 'ui.hideContextSummary',
showMemoryUsage: 'ui.showMemoryUsage',
showLineNumbers: 'ui.showLineNumbers',
showCitations: 'ui.showCitations',
ideMode: 'ide.enabled',
includeDirectories: 'context.includeDirectories',
loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories',
maxSessionTurns: 'model.maxSessionTurns',
mcpServers: 'mcpServers',
mcpServerCommand: 'mcp.serverCommand',
memoryImportFormat: 'context.importFormat',
memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs',
model: 'model.name',
preferredEditor: SettingPaths.General.PreferredEditor,
retryFetchErrors: 'general.retryFetchErrors',
sandbox: 'tools.sandbox',
selectedAuthType: 'security.auth.selectedType',
enableInteractiveShell: 'tools.shell.enableInteractiveShell',
shellPager: 'tools.shell.pager',
shellShowColor: 'tools.shell.showColor',
shellInactivityTimeout: 'tools.shell.inactivityTimeout',
skipNextSpeakerCheck: 'model.skipNextSpeakerCheck',
summarizeToolOutput: 'model.summarizeToolOutput',
telemetry: 'telemetry',
theme: 'ui.theme',
toolDiscoveryCommand: 'tools.discoveryCommand',
toolCallCommand: 'tools.callCommand',
usageStatisticsEnabled: 'privacy.usageStatisticsEnabled',
useExternalAuth: 'security.auth.useExternal',
useRipgrep: 'tools.useRipgrep',
vimMode: 'general.vimMode',
};
export function getSystemSettingsPath(): string {
if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) {
return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
@@ -268,162 +192,6 @@ function setNestedProperty(
current[lastKey] = value;
}
export function needsMigration(settings: Record<string, unknown>): boolean {
// A file needs migration if it contains any top-level key that is moved to a
// nested location in V2.
const hasV1Keys = Object.entries(MIGRATION_MAP).some(([v1Key, v2Path]) => {
if (v1Key === v2Path || !(v1Key in settings)) {
return false;
}
// If a key exists that is a V1 key and a V2 container (like 'model'),
// we need to check the type. If it's an object, it's a V2 container and not
// a V1 key that needs migration.
if (
KNOWN_V2_CONTAINERS.has(v1Key) &&
typeof settings[v1Key] === 'object' &&
settings[v1Key] !== null
) {
return false;
}
return true;
});
return hasV1Keys;
}
function migrateSettingsToV2(
flatSettings: Record<string, unknown>,
): Record<string, unknown> | null {
if (!needsMigration(flatSettings)) {
return null;
}
const v2Settings: Record<string, unknown> = {};
const flatKeys = new Set(Object.keys(flatSettings));
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
if (flatKeys.has(oldKey)) {
// If the key exists and is a V2 container (like 'model'), and the value is an object,
// it is likely already migrated or partially migrated. We should not move it
// to the mapped V2 path (e.g. 'model' -> 'model.name').
// Instead, let it fall through to the "Carry over" section to be merged.
if (
KNOWN_V2_CONTAINERS.has(oldKey) &&
typeof flatSettings[oldKey] === 'object' &&
flatSettings[oldKey] !== null &&
!Array.isArray(flatSettings[oldKey])
) {
continue;
}
setNestedProperty(v2Settings, newPath, flatSettings[oldKey]);
flatKeys.delete(oldKey);
}
}
// Preserve mcpServers at the top level
if (flatSettings['mcpServers']) {
v2Settings['mcpServers'] = flatSettings['mcpServers'];
flatKeys.delete('mcpServers');
}
// Carry over any unrecognized keys
for (const remainingKey of flatKeys) {
const existingValue = v2Settings[remainingKey];
const newValue = flatSettings[remainingKey];
if (
typeof existingValue === 'object' &&
existingValue !== null &&
!Array.isArray(existingValue) &&
typeof newValue === 'object' &&
newValue !== null &&
!Array.isArray(newValue)
) {
const pathAwareGetStrategy = (path: string[]) =>
getMergeStrategyForPath([remainingKey, ...path]);
v2Settings[remainingKey] = customDeepMerge(
pathAwareGetStrategy,
{},
existingValue as MergeableObject,
newValue as MergeableObject,
);
} else {
v2Settings[remainingKey] = newValue;
}
}
return v2Settings;
}
function getNestedProperty(
obj: Record<string, unknown>,
path: string,
): unknown {
const keys = path.split('.');
let current: unknown = obj;
for (const key of keys) {
if (typeof current !== 'object' || current === null || !(key in current)) {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
}
const REVERSE_MIGRATION_MAP: Record<string, string> = Object.fromEntries(
Object.entries(MIGRATION_MAP).map(([key, value]) => [value, key]),
);
// Dynamically determine the top-level keys from the V2 settings structure.
const KNOWN_V2_CONTAINERS = new Set(
Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]),
);
export function migrateSettingsToV1(
v2Settings: Record<string, unknown>,
): Record<string, unknown> {
const v1Settings: Record<string, unknown> = {};
const v2Keys = new Set(Object.keys(v2Settings));
for (const [newPath, oldKey] of Object.entries(REVERSE_MIGRATION_MAP)) {
const value = getNestedProperty(v2Settings, newPath);
if (value !== undefined) {
v1Settings[oldKey] = value;
v2Keys.delete(newPath.split('.')[0]);
}
}
// Preserve mcpServers at the top level
if (v2Settings['mcpServers']) {
v1Settings['mcpServers'] = v2Settings['mcpServers'];
v2Keys.delete('mcpServers');
}
// Carry over any unrecognized keys
for (const remainingKey of v2Keys) {
const value = v2Settings[remainingKey];
if (value === undefined) {
continue;
}
// Don't carry over empty objects that were just containers for migrated settings.
if (
KNOWN_V2_CONTAINERS.has(remainingKey) &&
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
Object.keys(value).length === 0
) {
continue;
}
v1Settings[remainingKey] = value;
}
return v1Settings;
}
export function getDefaultsFromSchema(
schema: SettingsSchema = getSettingsSchema(),
): Settings {
@@ -476,7 +244,6 @@ export class LoadedSettings {
user: SettingsFile,
workspace: SettingsFile,
isTrusted: boolean,
migratedInMemoryScopes: Set<SettingScope>,
errors: SettingsError[] = [],
) {
this.system = system;
@@ -484,7 +251,6 @@ export class LoadedSettings {
this.user = user;
this.workspace = workspace;
this.isTrusted = isTrusted;
this.migratedInMemoryScopes = migratedInMemoryScopes;
this.errors = errors;
this._merged = this.computeMergedSettings();
}
@@ -494,7 +260,6 @@ export class LoadedSettings {
readonly user: SettingsFile;
readonly workspace: SettingsFile;
readonly isTrusted: boolean;
readonly migratedInMemoryScopes: Set<SettingScope>;
readonly errors: SettingsError[];
private _merged: Settings;
@@ -534,6 +299,7 @@ export class LoadedSettings {
setNestedProperty(settingsFile.originalSettings, key, value);
this._merged = this.computeMergedSettings();
saveSettings(settingsFile);
coreEvents.emitSettingsChanged();
}
}
@@ -644,7 +410,6 @@ export function loadSettings(
const settingsErrors: SettingsError[] = [];
const systemSettingsPath = getSystemSettingsPath();
const systemDefaultsPath = getSystemDefaultsPath();
const migratedInMemoryScopes = new Set<SettingScope>();
// Resolve paths to their canonical representation to handle symlinks
const resolvedWorkspaceDir = path.resolve(workspaceDir);
@@ -665,10 +430,7 @@ export function loadSettings(
workspaceDir,
).getWorkspaceSettingsPath();
const loadAndMigrate = (
filePath: string,
scope: SettingScope,
): { settings: Settings; rawJson?: string } => {
const load = (filePath: string): { settings: Settings; rawJson?: string } => {
try {
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf-8');
@@ -687,33 +449,9 @@ export function loadSettings(
return { settings: {} };
}
let settingsObject = rawSettings as Record<string, unknown>;
if (needsMigration(settingsObject)) {
const migratedSettings = migrateSettingsToV2(settingsObject);
if (migratedSettings) {
if (MIGRATE_V2_OVERWRITE) {
try {
fs.renameSync(filePath, `${filePath}.orig`);
fs.writeFileSync(
filePath,
JSON.stringify(migratedSettings, null, 2),
'utf-8',
);
} catch (e) {
coreEvents.emitFeedback(
'error',
'Failed to migrate settings file.',
e,
);
}
} else {
migratedInMemoryScopes.add(scope);
}
settingsObject = migratedSettings;
}
}
const settingsObject = rawSettings as Record<string, unknown>;
// Validate settings structure with Zod after migration
// Validate settings structure with Zod
const validationResult = validateSettings(settingsObject);
if (!validationResult.success && validationResult.error) {
const errorMessage = formatValidationError(
@@ -739,22 +477,16 @@ export function loadSettings(
return { settings: {} };
};
const systemResult = loadAndMigrate(systemSettingsPath, SettingScope.System);
const systemDefaultsResult = loadAndMigrate(
systemDefaultsPath,
SettingScope.SystemDefaults,
);
const userResult = loadAndMigrate(USER_SETTINGS_PATH, SettingScope.User);
const systemResult = load(systemSettingsPath);
const systemDefaultsResult = load(systemDefaultsPath);
const userResult = load(USER_SETTINGS_PATH);
let workspaceResult: { settings: Settings; rawJson?: string } = {
settings: {} as Settings,
rawJson: undefined,
};
if (realWorkspaceDir !== realHomeDir) {
workspaceResult = loadAndMigrate(
workspaceSettingsPath,
SettingScope.Workspace,
);
workspaceResult = load(workspaceSettingsPath);
}
const systemOriginalSettings = structuredClone(systemResult.settings);
@@ -842,37 +574,10 @@ export function loadSettings(
rawJson: workspaceResult.rawJson,
},
isTrusted,
migratedInMemoryScopes,
settingsErrors,
);
}
export function migrateDeprecatedSettings(
loadedSettings: LoadedSettings,
extensionManager: ExtensionManager,
): void {
const processScope = (scope: LoadableSettingScope) => {
const settings = loadedSettings.forScope(scope).settings;
if (settings.extensions?.disabled) {
debugLogger.log(
`Migrating deprecated extensions.disabled settings from ${scope} settings...`,
);
for (const extension of settings.extensions.disabled ?? []) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
extensionManager.disableExtension(extension, scope);
}
const newExtensionsValue = { ...settings.extensions };
newExtensionsValue.disabled = undefined;
loadedSettings.setValue(scope, 'extensions', newExtensionsValue);
}
};
processScope(SettingScope.User);
processScope(SettingScope.Workspace);
}
export function saveSettings(settingsFile: SettingsFile): void {
try {
// Ensure the directory exists
@@ -881,12 +586,7 @@ export function saveSettings(settingsFile: SettingsFile): void {
fs.mkdirSync(dirPath, { recursive: true });
}
let settingsToSave = settingsFile.originalSettings;
if (!MIGRATE_V2_OVERWRITE) {
settingsToSave = migrateSettingsToV1(
settingsToSave as Record<string, unknown>,
) as Settings;
}
const settingsToSave = settingsFile.originalSettings;
// Use the format-preserving update function
updateSettingsFilePreservingFormat(
@@ -357,6 +357,15 @@ describe('SettingsSchema', () => {
);
});
it('should have hooks.notifications setting in schema', () => {
const setting = getSettingsSchema().hooks.properties.notifications;
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('Advanced');
expect(setting.default).toBe(true);
expect(setting.showInDialog).toBe(true);
});
it('should have name and description in hook definitions', () => {
const hookDef = SETTINGS_SCHEMA_DEFINITIONS['HookDefinitionArray'];
expect(hookDef).toBeDefined();
+108 -5
View File
@@ -1075,12 +1075,12 @@ const SETTINGS_SCHEMA = {
},
enableHooks: {
type: 'boolean',
label: 'Enable Hooks System',
label: 'Enable Hooks System (Experimental)',
category: 'Advanced',
requiresRestart: true,
default: false,
default: true,
description:
'Enable the hooks system for intercepting and customizing Gemini CLI behavior. When enabled, hooks configured in settings will execute at appropriate lifecycle events (BeforeTool, AfterTool, BeforeModel, etc.). Requires MessageBus integration.',
'Enables the hooks system experiment. When disabled, the hooks system is completely deactivated regardless of other settings.',
showInDialog: false,
},
},
@@ -1381,7 +1381,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: true,
default: false,
description: 'Enable Agent Skills (experimental).',
showInDialog: false,
showInDialog: true,
},
codebaseInvestigatorSettings: {
type: 'object',
@@ -1443,6 +1443,16 @@ const SETTINGS_SCHEMA = {
},
},
},
useOSC52Paste: {
type: 'boolean',
label: 'Use OSC 52 Paste',
category: 'Experimental',
requiresRestart: false,
default: false,
description:
'Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions).',
showInDialog: true,
},
introspectionAgentSettings: {
type: 'object',
label: 'Introspection Agent Settings',
@@ -1508,7 +1518,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: true,
default: {},
description: 'Settings for agent skills.',
showInDialog: true,
showInDialog: false,
properties: {
disabled: {
type: 'array',
@@ -1534,6 +1544,16 @@ const SETTINGS_SCHEMA = {
'Hook configurations for intercepting and customizing agent behavior.',
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
label: 'Enable Hooks',
category: 'Advanced',
requiresRestart: false,
default: false,
description:
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
showInDialog: false,
},
disabled: {
type: 'array',
label: 'Disabled Hooks',
@@ -1549,6 +1569,15 @@ const SETTINGS_SCHEMA = {
},
mergeStrategy: MergeStrategy.UNION,
},
notifications: {
type: 'boolean',
label: 'Hook Notifications',
category: 'Advanced',
requiresRestart: false,
default: true,
description: 'Show visual indicators when hooks are executing.',
showInDialog: true,
},
BeforeTool: {
type: 'array',
label: 'Before Tool Hooks',
@@ -1689,6 +1718,74 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.CONCAT,
},
},
admin: {
type: 'object',
label: 'Admin',
category: 'Admin',
requiresRestart: false,
default: {},
description: 'Settings configured remotely by enterprise admins.',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
properties: {
secureModeEnabled: {
type: 'boolean',
label: 'Secure Mode Enabled',
category: 'Admin',
requiresRestart: false,
default: false,
description: 'If true, disallows yolo mode from being used.',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
},
extensions: {
type: 'object',
label: 'Extensions Settings',
category: 'Admin',
requiresRestart: false,
default: {},
description: 'Extensions-specific admin settings.',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
properties: {
enabled: {
type: 'boolean',
label: 'Extensions Enabled',
category: 'Admin',
requiresRestart: false,
default: true,
description:
'If false, disallows extensions from being installed or used.',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
},
},
},
mcp: {
type: 'object',
label: 'MCP Settings',
category: 'Admin',
requiresRestart: false,
default: {},
description: 'MCP-specific admin settings.',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
properties: {
enabled: {
type: 'boolean',
label: 'MCP Enabled',
category: 'Admin',
requiresRestart: false,
default: true,
description: 'If false, disallows MCP servers from being used.',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
},
},
},
},
},
} as const satisfies SettingsSchema;
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
@@ -2038,3 +2135,9 @@ type InferSettings<T extends SettingsSchema> = {
};
export type Settings = InferSettings<SettingsSchemaType>;
export function getEnableHooks(settings: Settings): boolean {
return (
(settings.tools?.enableHooks ?? true) && (settings.hooks?.enabled ?? false)
);
}
+1 -20
View File
@@ -20,11 +20,7 @@ import {
loadTrustedFolders,
type TrustedFoldersError,
} from './config/trustedFolders.js';
import {
loadSettings,
migrateDeprecatedSettings,
SettingScope,
} from './config/settings.js';
import { loadSettings, SettingScope } from './config/settings.js';
import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
@@ -95,9 +91,7 @@ import {
} from './utils/relaunch.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { deleteSession, listSessions } from './utils/sessions.js';
import { ExtensionManager } from './config/extension-manager.js';
import { createPolicyUpdater } from './config/policy.js';
import { requestConsentNonInteractive } from './config/extensions/consent.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
@@ -317,19 +311,6 @@ export async function main() {
);
});
const migrateHandle = startupProfiler.start('migrate_settings');
migrateDeprecatedSettings(
settings,
// Temporary extension manager only used during this non-interactive UI phase.
new ExtensionManager({
workspaceDir: process.cwd(),
settings: settings.merged,
enabledExtensionOverrides: [],
requestConsent: requestConsentNonInteractive,
requestSetting: null,
}),
);
migrateHandle?.end();
await cleanupCheckpoints();
const parseArgsHandle = startupProfiler.start('parse_arguments');
@@ -102,6 +102,7 @@ describe('BuiltinCommandLoader', () => {
getEnableExtensionReloading: () => false,
getEnableHooks: () => false,
isSkillsSupportEnabled: vi.fn().mockReturnValue(false),
getMcpEnabled: vi.fn().mockReturnValue(true),
getSkillManager: vi.fn().mockReturnValue({
getAllSkills: vi.fn().mockReturnValue([]),
}),
@@ -179,6 +180,7 @@ describe('BuiltinCommandLoader', () => {
const mockConfigWithMessageBus = {
...mockConfig,
getEnableHooks: () => false,
getMcpEnabled: () => true,
} as unknown as Config;
const loader = new BuiltinCommandLoader(mockConfigWithMessageBus);
const commands = await loader.loadCommands(new AbortController().signal);
@@ -198,6 +200,7 @@ describe('BuiltinCommandLoader profile', () => {
getEnableExtensionReloading: () => false,
getEnableHooks: () => false,
isSkillsSupportEnabled: vi.fn().mockReturnValue(false),
getMcpEnabled: vi.fn().mockReturnValue(true),
getSkillManager: vi.fn().mockReturnValue({
getAllSkills: vi.fn().mockReturnValue([]),
}),
@@ -6,8 +6,12 @@
import { isDevelopment } from '../utils/installationInfo.js';
import type { ICommandLoader } from './types.js';
import type { SlashCommand } from '../ui/commands/types.js';
import type { Config } from '@google/gemini-cli-core';
import {
CommandKind,
type SlashCommand,
type CommandContext,
} from '../ui/commands/types.js';
import type { MessageActionReturn, Config } from '@google/gemini-cli-core';
import { startupProfiler } from '@google/gemini-cli-core';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
@@ -77,7 +81,25 @@ export class BuiltinCommandLoader implements ICommandLoader {
...(this.config?.getEnableHooks() ? [hooksCommand] : []),
await ideCommand(),
initCommand,
mcpCommand,
...(this.config?.getMcpEnabled() === false
? [
{
name: 'mcp',
description:
'Manage configured Model Context Protocol (MCP) servers',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [],
action: async (
_context: CommandContext,
): Promise<MessageActionReturn> => ({
type: 'message',
messageType: 'error',
content: 'MCP disabled by your admin.',
}),
},
]
: [mcpCommand]),
memoryCommand,
modelCommand,
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
+2 -2
View File
@@ -109,7 +109,7 @@ export const mockSettings = new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
true,
new Set(),
[],
);
export const createMockSettings = (
@@ -122,7 +122,7 @@ export const createMockSettings = (
{ path: '', settings, originalSettings: settings },
{ path: '', settings: {}, originalSettings: {} },
true,
new Set(),
[],
);
};
+2 -6
View File
@@ -14,11 +14,7 @@ import { StreamingState } from './types.js';
import { ConfigContext } from './contexts/ConfigContext.js';
import { AppContext, type AppState } from './contexts/AppContext.js';
import { SettingsContext } from './contexts/SettingsContext.js';
import {
type SettingScope,
LoadedSettings,
type SettingsFile,
} from '../config/settings.js';
import { LoadedSettings, type SettingsFile } from '../config/settings.js';
vi.mock('ink', async (importOriginal) => {
const original = await importOriginal<typeof import('ink')>();
@@ -92,7 +88,7 @@ describe('App', () => {
mockSettingsFile,
mockSettingsFile,
true,
new Set<SettingScope>(),
[],
);
const mockAppState: AppState = {
+19
View File
@@ -143,6 +143,7 @@ vi.mock('./contexts/SessionContext.js');
vi.mock('./components/shared/text-buffer.js');
vi.mock('./hooks/useLogger.js');
vi.mock('./hooks/useInputHistoryStore.js');
vi.mock('./hooks/useHookDisplayState.js');
// Mock external utilities
vi.mock('../utils/events.js');
@@ -171,6 +172,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { measureElement } from 'ink';
import { useTerminalSize } from './hooks/useTerminalSize.js';
@@ -243,6 +245,7 @@ describe('AppContainer State Management', () => {
const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
const mockedUseKeypress = useKeypress as Mock;
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
const mockedUseHookDisplayState = useHookDisplayState as Mock;
beforeEach(() => {
vi.clearAllMocks();
@@ -363,6 +366,7 @@ describe('AppContainer State Management', () => {
elapsedTime: '0.0s',
currentLoadingPhrase: '',
});
mockedUseHookDisplayState.mockReturnValue([]);
// Mock Config
mockConfig = makeFakeConfig();
@@ -1874,6 +1878,21 @@ describe('AppContainer State Management', () => {
expect(capturedUIState.currentModel).toBe('new-model');
unmount!();
});
it('provides activeHooks from useHookDisplayState', async () => {
const mockHooks = [{ name: 'hook1', eventName: 'event1' }];
mockedUseHookDisplayState.mockReturnValue(mockHooks);
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
expect(capturedUIState.activeHooks).toEqual(mockHooks);
unmount!();
});
});
describe('Shell Interaction', () => {
+22 -3
View File
@@ -123,9 +123,11 @@ import { useSettings } from './contexts/SettingsContext.js';
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useBanner } from './hooks/useBanner.js';
const WARNING_PROMPT_DURATION_MS = 1000;
const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import {
WARNING_PROMPT_DURATION_MS,
QUEUE_ERROR_DISPLAY_DURATION_MS,
} from './constants.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
@@ -188,6 +190,8 @@ export const AppContainer = (props: AppContainerProps) => {
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
useState<boolean>(false);
const [historyRemountKey, setHistoryRemountKey] = useState(0);
const [settingsNonce, setSettingsNonce] = useState(0);
const activeHooks = useHookDisplayState();
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
isWorkspaceTrusted(settings.merged).isTrusted,
@@ -368,6 +372,17 @@ export const AppContainer = (props: AppContainerProps) => {
};
}, [config]);
useEffect(() => {
const handleSettingsChanged = () => {
setSettingsNonce((prev) => prev + 1);
};
coreEvents.on(CoreEvent.SettingsChanged, handleSettingsChanged);
return () => {
coreEvents.off(CoreEvent.SettingsChanged, handleSettingsChanged);
};
}, []);
const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } =
useConsoleMessages();
@@ -1510,6 +1525,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
elapsedTime,
currentLoadingPhrase,
historyRemountKey,
activeHooks,
messageQueue,
queueErrorMessage,
showAutoAcceptIndicator,
@@ -1546,6 +1562,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
bannerData,
bannerVisible,
terminalBackgroundColor: config.getTerminalBackground(),
settingsNonce,
}),
[
isThemeDialogOpen,
@@ -1599,6 +1616,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
elapsedTime,
currentLoadingPhrase,
historyRemountKey,
activeHooks,
messageQueue,
queueErrorMessage,
showAutoAcceptIndicator,
@@ -1638,6 +1656,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
bannerData,
bannerVisible,
config,
settingsNonce,
],
);
@@ -147,7 +147,7 @@ describe('hooksCommand', () => {
type: 'message',
messageType: 'info',
content:
'Hook system is not enabled. Enable it in settings with tools.enableHooks',
'Hook system is not enabled. Enable it in settings with hooks.enabled.',
});
});
+1 -1
View File
@@ -35,7 +35,7 @@ async function panelAction(
type: 'message',
messageType: 'info',
content:
'Hook system is not enabled. Enable it in settings with tools.enableHooks',
'Hook system is not enabled. Enable it in settings with hooks.enabled.',
};
}
@@ -1,21 +1,22 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { skillsCommand } from './skillsCommand.js';
import { MessageType } from '../types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { CommandContext } from './types.js';
import type { Config } from '@google/gemini-cli-core';
import type { Config, SkillDefinition } from '@google/gemini-cli-core';
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
describe('skillsCommand', () => {
let context: CommandContext;
beforeEach(() => {
vi.useFakeTimers();
const skills = [
{
name: 'skill1',
@@ -35,6 +36,7 @@ describe('skillsCommand', () => {
config: {
getSkillManager: vi.fn().mockReturnValue({
getAllSkills: vi.fn().mockReturnValue(skills),
getSkills: vi.fn().mockReturnValue(skills),
getSkill: vi
.fn()
.mockImplementation(
@@ -51,6 +53,11 @@ describe('skillsCommand', () => {
});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should add a SKILLS_LIST item to UI with descriptions by default', async () => {
await skillsCommand.action!(context, '');
@@ -187,6 +194,170 @@ describe('skillsCommand', () => {
});
});
describe('reload', () => {
it('should reload skills successfully and show success message', async () => {
const reloadCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'reload',
)!;
// Make reload take some time so timer can fire
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
});
// @ts-expect-error Mocking reloadSkills
context.services.config.reloadSkills = reloadSkillsMock;
const actionPromise = reloadCmd.action!(context, '');
// Initially, no pending item (flicker prevention)
expect(context.ui.setPendingItem).not.toHaveBeenCalled();
// Fast forward 100ms to trigger the pending item
await vi.advanceTimersByTimeAsync(100);
expect(context.ui.setPendingItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Reloading agent skills...',
}),
);
// Fast forward another 100ms (reload complete), but pending item should stay
await vi.advanceTimersByTimeAsync(100);
expect(context.ui.setPendingItem).not.toHaveBeenCalledWith(null);
// Fast forward to reach 500ms total
await vi.advanceTimersByTimeAsync(300);
await actionPromise;
expect(reloadSkillsMock).toHaveBeenCalled();
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Agent skills reloaded successfully.',
}),
expect.any(Number),
);
});
it('should show new skills count after reload', async () => {
const reloadCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'reload',
)!;
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
const skillManager = context.services.config!.getSkillManager();
vi.mocked(skillManager.getSkills).mockReturnValue([
{ name: 'skill1' },
{ name: 'skill2' },
{ name: 'skill3' },
] as SkillDefinition[]);
});
// @ts-expect-error Mocking reloadSkills
context.services.config.reloadSkills = reloadSkillsMock;
await reloadCmd.action!(context, '');
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Agent skills reloaded successfully. 1 newly available skill.',
}),
expect.any(Number),
);
});
it('should show removed skills count after reload', async () => {
const reloadCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'reload',
)!;
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
const skillManager = context.services.config!.getSkillManager();
vi.mocked(skillManager.getSkills).mockReturnValue([
{ name: 'skill1' },
] as SkillDefinition[]);
});
// @ts-expect-error Mocking reloadSkills
context.services.config.reloadSkills = reloadSkillsMock;
await reloadCmd.action!(context, '');
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Agent skills reloaded successfully. 1 skill no longer available.',
}),
expect.any(Number),
);
});
it('should show both added and removed skills count after reload', async () => {
const reloadCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'reload',
)!;
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
const skillManager = context.services.config!.getSkillManager();
vi.mocked(skillManager.getSkills).mockReturnValue([
{ name: 'skill2' }, // skill1 removed, skill3 added
{ name: 'skill3' },
] as SkillDefinition[]);
});
// @ts-expect-error Mocking reloadSkills
context.services.config.reloadSkills = reloadSkillsMock;
await reloadCmd.action!(context, '');
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Agent skills reloaded successfully. 1 newly available skill and 1 skill no longer available.',
}),
expect.any(Number),
);
});
it('should show error if configuration is missing', async () => {
const reloadCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'reload',
)!;
context.services.config = null;
await reloadCmd.action!(context, '');
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Could not retrieve configuration.',
}),
expect.any(Number),
);
});
it('should show error if reload fails', async () => {
const reloadCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'reload',
)!;
const error = new Error('Reload failed');
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
await new Promise((_, reject) => setTimeout(() => reject(error), 200));
});
// @ts-expect-error Mocking reloadSkills
context.services.config.reloadSkills = reloadSkillsMock;
const actionPromise = reloadCmd.action!(context, '');
await vi.advanceTimersByTimeAsync(100);
await vi.advanceTimersByTimeAsync(400);
await actionPromise;
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Failed to reload skills: Reload failed',
}),
expect.any(Number),
);
});
});
describe('completions', () => {
it('should provide completions for disable (only enabled skills)', async () => {
const disableCmd = skillsCommand.subCommands!.find(
+111 -5
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -10,7 +10,11 @@ import {
type SlashCommandActionReturn,
CommandKind,
} from './types.js';
import { MessageType, type HistoryItemSkillsList } from '../types.js';
import {
MessageType,
type HistoryItemSkillsList,
type HistoryItemInfo,
} from '../types.js';
import { SettingScope } from '../../config/settings.js';
async function listAction(
@@ -104,7 +108,7 @@ async function disableAction(
context.ui.addItem(
{
type: MessageType.INFO,
text: `Skill "${skillName}" disabled in ${scope} settings. Restart required to take effect.`,
text: `Skill "${skillName}" disabled in ${scope} settings. Use "/skills reload" for it to take effect.`,
},
Date.now(),
);
@@ -148,12 +152,107 @@ async function enableAction(
context.ui.addItem(
{
type: MessageType.INFO,
text: `Skill "${skillName}" enabled in ${scope} settings. Restart required to take effect.`,
text: `Skill "${skillName}" enabled in ${scope} settings. Use "/skills reload" for it to take effect.`,
},
Date.now(),
);
}
async function reloadAction(
context: CommandContext,
): Promise<void | SlashCommandActionReturn> {
const config = context.services.config;
if (!config) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Could not retrieve configuration.',
},
Date.now(),
);
return;
}
const skillManager = config.getSkillManager();
const beforeNames = new Set(skillManager.getSkills().map((s) => s.name));
const startTime = Date.now();
let pendingItemSet = false;
const pendingTimeout = setTimeout(() => {
context.ui.setPendingItem({
type: MessageType.INFO,
text: 'Reloading agent skills...',
});
pendingItemSet = true;
}, 100);
try {
await config.reloadSkills();
clearTimeout(pendingTimeout);
if (pendingItemSet) {
// If we showed the pending item, make sure it stays for at least 500ms
// total to avoid a "flicker" where it appears and immediately disappears.
const elapsed = Date.now() - startTime;
const minVisibleDuration = 500;
if (elapsed < minVisibleDuration) {
await new Promise((resolve) =>
setTimeout(resolve, minVisibleDuration - elapsed),
);
}
context.ui.setPendingItem(null);
}
const afterSkills = skillManager.getSkills();
const afterNames = new Set(afterSkills.map((s) => s.name));
const added = afterSkills.filter((s) => !beforeNames.has(s.name));
const removedCount = [...beforeNames].filter(
(name) => !afterNames.has(name),
).length;
let successText = 'Agent skills reloaded successfully.';
const details: string[] = [];
if (added.length > 0) {
details.push(
`${added.length} newly available skill${added.length > 1 ? 's' : ''}`,
);
}
if (removedCount > 0) {
details.push(
`${removedCount} skill${removedCount > 1 ? 's' : ''} no longer available`,
);
}
if (details.length > 0) {
successText += ` ${details.join(' and ')}.`;
}
context.ui.addItem(
{
type: 'info',
text: successText,
icon: '✓ ',
color: 'green',
} as HistoryItemInfo,
Date.now(),
);
} catch (error) {
clearTimeout(pendingTimeout);
if (pendingItemSet) {
context.ui.setPendingItem(null);
}
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Failed to reload skills: ${error instanceof Error ? error.message : String(error)}`,
},
Date.now(),
);
}
}
function disableCompletion(
context: CommandContext,
partialArg: string,
@@ -185,7 +284,7 @@ function enableCompletion(
export const skillsCommand: SlashCommand = {
name: 'skills',
description:
'List, enable, or disable Gemini CLI agent skills. Usage: /skills [list | disable <name> | enable <name>]',
'List, enable, disable, or reload Gemini CLI agent skills. Usage: /skills [list | disable <name> | enable <name> | reload]',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [
@@ -210,6 +309,13 @@ export const skillsCommand: SlashCommand = {
action: enableAction,
completion: enableCompletion,
},
{
name: 'reload',
description:
'Reload the list of discovered skills. Usage: /skills reload',
kind: CommandKind.BUILT_IN,
action: reloadAction,
},
],
action: listAction,
};
@@ -36,6 +36,10 @@ vi.mock('./ContextSummaryDisplay.js', () => ({
ContextSummaryDisplay: () => <Text>ContextSummaryDisplay</Text>,
}));
vi.mock('./HookStatusDisplay.js', () => ({
HookStatusDisplay: () => <Text>HookStatusDisplay</Text>,
}));
vi.mock('./AutoAcceptIndicator.js', () => ({
AutoAcceptIndicator: () => <Text>AutoAcceptIndicator</Text>,
}));
@@ -125,6 +129,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
errorCount: 0,
nightly: false,
isTrustedFolder: true,
activeHooks: [],
...overrides,
}) as UIState;
@@ -341,6 +346,17 @@ describe('Composer', () => {
expect(lastFrame()).toContain('ContextSummaryDisplay');
});
it('renders HookStatusDisplay instead of ContextSummaryDisplay with active hooks', () => {
const uiState = createMockUIState({
activeHooks: [{ name: 'test-hook', eventName: 'before-agent' }],
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('HookStatusDisplay');
expect(lastFrame()).not.toContain('ContextSummaryDisplay');
});
it('shows Ctrl+C exit prompt when ctrlCPressedOnce is true', () => {
const uiState = createMockUIState({
ctrlCPressedOnce: true,
+4 -36
View File
@@ -5,9 +5,9 @@
*/
import { useState } from 'react';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { Box, useIsScreenReaderEnabled } from 'ink';
import { LoadingIndicator } from './LoadingIndicator.js';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
import { StatusDisplay } from './StatusDisplay.js';
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
@@ -17,7 +17,6 @@ import { Footer } from './Footer.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { theme } from '../semantic-colors.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
@@ -43,7 +42,7 @@ export const Composer = () => {
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
const isAlternateBuffer = useAlternateBuffer();
const { contextFileNames, showAutoAcceptIndicator } = uiState;
const { showAutoAcceptIndicator } = uiState;
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
const hideContextSummary =
suggestionsVisible && suggestionsPosition === 'above';
@@ -92,38 +91,7 @@ export const Composer = () => {
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box marginRight={1}>
{process.env['GEMINI_SYSTEM_MD'] && (
<Text color={theme.status.error}>|_| </Text>
)}
{uiState.ctrlCPressedOnce ? (
<Text color={theme.status.warning}>
Press Ctrl+C again to exit.
</Text>
) : uiState.warningMessage ? (
<Text color={theme.status.warning}>{uiState.warningMessage}</Text>
) : uiState.ctrlDPressedOnce ? (
<Text color={theme.status.warning}>
Press Ctrl+D again to exit.
</Text>
) : uiState.showEscapePrompt ? (
<Text color={theme.text.secondary}>Press Esc again to clear.</Text>
) : uiState.queueErrorMessage ? (
<Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>
) : (
!settings.merged.ui?.hideContextSummary &&
!hideContextSummary && (
<ContextSummaryDisplay
ideContext={uiState.ideContextState}
geminiMdFileCount={uiState.geminiMdFileCount}
contextFileNames={contextFileNames}
mcpServers={config.getMcpClientManager()?.getMcpServers() ?? {}}
blockedMcpServers={
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
}
skillCount={config.getSkillManager().getSkills().length}
/>
)
)}
<StatusDisplay hideContextSummary={hideContextSummary} />
</Box>
<Box paddingTop={isNarrow ? 1 : 0}>
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
@@ -6,7 +6,7 @@
import type React from 'react';
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
@@ -16,6 +16,11 @@ vi.mock('../hooks/useTerminalSize.js', () => ({
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
const renderWithWidth = (
width: number,
props: React.ComponentProps<typeof ContextSummaryDisplay>,
@@ -26,48 +31,68 @@ const renderWithWidth = (
describe('<ContextSummaryDisplay />', () => {
const baseProps = {
geminiMdFileCount: 1,
contextFileNames: ['GEMINI.md'],
mcpServers: { 'test-server': { command: 'test' } },
geminiMdFileCount: 0,
contextFileNames: [],
mcpServers: {},
ideContext: {
workspaceState: {
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
openFiles: [],
},
},
skillCount: 1,
};
it('should render on a single line on a wide screen', () => {
const { lastFrame, unmount } = renderWithWidth(120, baseProps);
const output = lastFrame()!;
expect(output).toContain(
'1 open file (ctrl+g to view) | 1 GEMINI.md file | 1 MCP server | 1 skill',
);
expect(output).not.toContain('Using:');
// Check for absence of newlines
expect(output.includes('\n')).toBe(false);
const props = {
...baseProps,
geminiMdFileCount: 1,
contextFileNames: ['GEMINI.md'],
mcpServers: { 'test-server': { command: 'test' } },
ideContext: {
workspaceState: {
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
},
},
};
const { lastFrame, unmount } = renderWithWidth(120, props);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render on multiple lines on a narrow screen', () => {
const { lastFrame, unmount } = renderWithWidth(60, baseProps);
const output = lastFrame()!;
const expectedLines = [
' - 1 open file (ctrl+g to view)',
' - 1 GEMINI.md file',
' - 1 MCP server',
' - 1 skill',
];
const actualLines = output.split('\n');
expect(actualLines).toEqual(expectedLines);
const props = {
...baseProps,
geminiMdFileCount: 1,
contextFileNames: ['GEMINI.md'],
mcpServers: { 'test-server': { command: 'test' } },
ideContext: {
workspaceState: {
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
},
},
};
const { lastFrame, unmount } = renderWithWidth(60, props);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should switch layout at the 80-column breakpoint', () => {
const props = {
...baseProps,
geminiMdFileCount: 1,
contextFileNames: ['GEMINI.md'],
mcpServers: { 'test-server': { command: 'test' } },
ideContext: {
workspaceState: {
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
},
},
};
// At 80 columns, should be on one line
const { lastFrame: wideFrame, unmount: unmountWide } = renderWithWidth(
80,
baseProps,
props,
);
expect(wideFrame()!.includes('\n')).toBe(false);
unmountWide();
@@ -75,13 +100,12 @@ describe('<ContextSummaryDisplay />', () => {
// At 79 columns, should be on multiple lines
const { lastFrame: narrowFrame, unmount: unmountNarrow } = renderWithWidth(
79,
baseProps,
props,
);
expect(narrowFrame()!.includes('\n')).toBe(true);
expect(narrowFrame()!.split('\n').length).toBe(4);
unmountNarrow();
});
it('should not render empty parts', () => {
const props = {
...baseProps,
@@ -89,11 +113,14 @@ describe('<ContextSummaryDisplay />', () => {
contextFileNames: [],
mcpServers: {},
skillCount: 0,
ideContext: {
workspaceState: {
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
},
},
};
const { lastFrame, unmount } = renderWithWidth(60, props);
const expectedLines = [' - 1 open file (ctrl+g to view)'];
const actualLines = lastFrame()!.split('\n');
expect(actualLines).toEqual(expectedLines);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
@@ -7,7 +7,7 @@
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { act } from 'react';
import { vi } from 'vitest';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { FolderTrustDialog } from './FolderTrustDialog.js';
import { ExitCodes } from '@google/gemini-cli-core';
import * as processUtils from '../../utils/processUtils.js';
@@ -74,7 +74,7 @@ describe('FolderTrustDialog', () => {
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
expect(lastFrame()).toContain(' Gemini CLI is restarting');
expect(lastFrame()).toContain('Gemini CLI is restarting');
});
it('should call relaunchApp when isRestarting is true', async () => {
@@ -88,6 +88,21 @@ describe('FolderTrustDialog', () => {
vi.useRealTimers();
});
it('should not call relaunchApp if unmounted before timeout', async () => {
vi.useFakeTimers();
const relaunchApp = vi.spyOn(processUtils, 'relaunchApp');
const { unmount } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
// Unmount immediately (before 250ms)
unmount();
await vi.advanceTimersByTimeAsync(250);
expect(relaunchApp).not.toHaveBeenCalled();
vi.useRealTimers();
});
it('should not call process.exit when "r" is pressed and isRestarting is false', async () => {
const { stdin } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={false} />,
@@ -6,7 +6,7 @@
import { Box, Text } from 'ink';
import type React from 'react';
import { useEffect, useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { theme } from '../semantic-colors.js';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
@@ -33,26 +33,32 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
isRestarting,
}) => {
const [exiting, setExiting] = useState(false);
useEffect(() => {
const doRelaunch = async () => {
if (isRestarting) {
setTimeout(async () => {
await relaunchApp();
}, 250);
}
let timer: ReturnType<typeof setTimeout>;
if (isRestarting) {
timer = setTimeout(async () => {
await relaunchApp();
}, 250);
}
return () => {
if (timer) clearTimeout(timer);
};
// eslint-disable-next-line @typescript-eslint/no-floating-promises
doRelaunch();
}, [isRestarting]);
const handleExit = useCallback(() => {
setExiting(true);
// Give time for the UI to render the exiting message
setTimeout(async () => {
await runExitCleanup();
process.exit(ExitCodes.FATAL_CANCELLATION_ERROR);
}, 100);
}, []);
useKeypress(
(key) => {
if (key.name === 'escape') {
setExiting(true);
setTimeout(async () => {
await runExitCleanup();
process.exit(ExitCodes.FATAL_CANCELLATION_ERROR);
}, 100);
handleExit();
}
},
{ isActive: !isRestarting },
@@ -0,0 +1,55 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { HookStatusDisplay } from './HookStatusDisplay.js';
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('<HookStatusDisplay />', () => {
it('should render a single executing hook', () => {
const props = {
activeHooks: [{ name: 'test-hook', eventName: 'BeforeAgent' }],
};
const { lastFrame, unmount } = render(<HookStatusDisplay {...props} />);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render multiple executing hooks', () => {
const props = {
activeHooks: [
{ name: 'h1', eventName: 'BeforeAgent' },
{ name: 'h2', eventName: 'BeforeAgent' },
],
};
const { lastFrame, unmount } = render(<HookStatusDisplay {...props} />);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render sequential hook progress', () => {
const props = {
activeHooks: [
{ name: 'step', eventName: 'BeforeAgent', index: 1, total: 3 },
],
};
const { lastFrame, unmount } = render(<HookStatusDisplay {...props} />);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should return empty string if no active hooks', () => {
const props = { activeHooks: [] };
const { lastFrame, unmount } = render(<HookStatusDisplay {...props} />);
expect(lastFrame()).toBe('');
unmount();
});
});
@@ -0,0 +1,39 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { type ActiveHook } from '../types.js';
interface HookStatusDisplayProps {
activeHooks: ActiveHook[];
}
export const HookStatusDisplay: React.FC<HookStatusDisplayProps> = ({
activeHooks,
}) => {
if (activeHooks.length === 0) {
return null;
}
const label = activeHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
const displayNames = activeHooks.map((hook) => {
let name = hook.name;
if (hook.index && hook.total && hook.total > 1) {
name += ` (${hook.index}/${hook.total})`;
}
return name;
});
const text = `${label}: ${displayNames.join(', ')}`;
return (
<Text color={theme.status.warning} wrap="truncate">
{text}
</Text>
);
};
@@ -4,7 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import {
renderWithProviders,
createMockSettings,
} from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { act } from 'react';
import type { InputPromptProps } from './InputPrompt.js';
@@ -598,7 +601,7 @@ describe('InputPrompt', () => {
});
await waitFor(() => {
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
'Error handling clipboard image:',
'Error handling paste:',
expect.any(Error),
);
});
@@ -633,6 +636,31 @@ describe('InputPrompt', () => {
});
unmount();
});
it('should use OSC 52 when useOSC52Paste setting is enabled', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
const settings = createMockSettings({
experimental: { useOSC52Paste: true },
});
const { stdout, stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{ settings },
);
const writeSpy = vi.spyOn(stdout, 'write');
await act(async () => {
stdin.write('\x16'); // Ctrl+V
});
await waitFor(() => {
expect(writeSpy).toHaveBeenCalledWith('\x1b]52;c;?\x07');
});
// Should NOT call clipboardy.read()
expect(clipboardy.read).not.toHaveBeenCalled();
unmount();
});
});
it.each([
+13 -6
View File
@@ -7,7 +7,7 @@
import type React from 'react';
import clipboardy from 'clipboardy';
import { useCallback, useEffect, useState, useRef } from 'react';
import { Box, Text, type DOMElement } from 'ink';
import { Box, Text, useStdout, type DOMElement } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
@@ -43,6 +43,7 @@ import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { StreamingState } from '../types.js';
import { useMouseClick } from '../hooks/useMouseClick.js';
import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
@@ -129,6 +130,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
suggestionsPosition = 'below',
setBannerVisible,
}) => {
const { stdout } = useStdout();
const { merged: settings } = useSettings();
const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState();
const { setEmbeddedShellFocused } = useUIActions();
@@ -350,13 +353,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}
const textToInsert = await clipboardy.read();
const offset = buffer.getOffset();
buffer.replaceRangeByOffset(offset, offset, textToInsert);
if (settings.experimental?.useOSC52Paste) {
stdout.write('\x1b]52;c;?\x07');
} else {
const textToInsert = await clipboardy.read();
const offset = buffer.getOffset();
buffer.replaceRangeByOffset(offset, offset, textToInsert);
}
} catch (error) {
debugLogger.error('Error handling clipboard image:', error);
debugLogger.error('Error handling paste:', error);
}
}, [buffer, config]);
}, [buffer, config, stdout, settings]);
useMouseClick(
innerBoxRef,
@@ -105,7 +105,7 @@ const createMockSettings = (
path: '/workspace/settings.json',
},
true,
new Set(),
[],
);
vi.mock('../../config/settingsSchema.js', async (importOriginal) => {
@@ -308,6 +308,23 @@ describe('SettingsDialog', () => {
});
});
describe('Setting Descriptions', () => {
it('should render descriptions for settings that have them', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame } = renderDialog(settings, onSelect);
const output = lastFrame();
// 'general.vimMode' has description 'Enable Vim keybindings' in settingsSchema.ts
expect(output).toContain('Vim Mode');
expect(output).toContain('Enable Vim keybindings');
// 'general.disableAutoUpdate' has description 'Disable automatic updates'
expect(output).toContain('Disable Auto Update');
expect(output).toContain('Disable automatic updates');
});
});
describe('Settings Navigation', () => {
it.each([
{
@@ -37,7 +37,12 @@ import {
import { useVimMode } from '../contexts/VimModeContext.js';
import { useKeypress } from '../hooks/useKeypress.js';
import chalk from 'chalk';
import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js';
import {
cpSlice,
cpLen,
stripUnsafeCharacters,
getCachedStringWidth,
} from '../utils/textUtils.js';
import {
type SettingsValue,
TOGGLE_TYPES,
@@ -65,7 +70,7 @@ interface SettingsDialogProps {
config?: Config;
}
const maxItemsToShow = 8;
const MAX_ITEMS_TO_SHOW = 8;
export function SettingsDialog({
settings,
@@ -194,6 +199,31 @@ export function SettingsDialog({
setShowRestartPrompt(newRestartRequired.size > 0);
}, [selectedScope, settings, globalPendingChanges]);
// Calculate max width for the left column (Label/Description) to keep values aligned or close
const maxLabelOrDescriptionWidth = useMemo(() => {
const allKeys = getDialogSettingKeys();
let max = 0;
for (const key of allKeys) {
const def = getSettingDefinition(key);
if (!def) continue;
const scopeMessage = getScopeMessageForSetting(
key,
selectedScope,
settings,
);
const label = def.label || key;
const labelFull = label + (scopeMessage ? ` ${scopeMessage}` : '');
const lWidth = getCachedStringWidth(labelFull);
const dWidth = def.description
? getCachedStringWidth(def.description)
: 0;
max = Math.max(max, lWidth, dWidth);
}
return max;
}, [selectedScope, settings]);
const generateSettingsItems = () => {
const settingKeys = searchQuery ? filteredKeys : getDialogSettingKeys();
@@ -202,6 +232,7 @@ export function SettingsDialog({
return {
label: definition?.label || key,
description: definition?.description,
value: key,
type: definition?.type,
toggle: () => {
@@ -478,8 +509,8 @@ export function SettingsDialog({
currentAvailableTerminalHeight - totalFixedHeight,
);
// Each setting item takes 2 lines (the setting row + spacing)
let maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 2));
// Each setting item takes up to 3 lines (label/value row, description row, and spacing)
let maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 3));
// Decide whether to show scope selection based on remaining space
let showScopeSelection = true;
@@ -492,7 +523,7 @@ export function SettingsDialog({
1,
currentAvailableTerminalHeight - totalWithScope,
);
const maxItemsWithScope = Math.max(1, Math.floor(availableWithScope / 2));
const maxItemsWithScope = Math.max(1, Math.floor(availableWithScope / 3));
// If hiding scope selection allows us to show significantly more settings, do it
if (maxVisibleItems > maxItemsWithScope + 1) {
@@ -504,7 +535,7 @@ export function SettingsDialog({
1,
currentAvailableTerminalHeight - totalFixedHeight,
);
maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 2));
maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 3));
}
} else {
// For normal height, include scope selection
@@ -513,13 +544,13 @@ export function SettingsDialog({
1,
currentAvailableTerminalHeight - totalFixedHeight,
);
maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 2));
maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 3));
}
// Use the calculated maxVisibleItems or fall back to the original maxItemsToShow
const effectiveMaxItemsToShow = availableTerminalHeight
? Math.min(maxVisibleItems, items.length)
: maxItemsToShow;
: MAX_ITEMS_TO_SHOW;
// Ensure focus stays on settings when scope selection is hidden
React.useEffect(() => {
@@ -979,7 +1010,7 @@ export function SettingsDialog({
return (
<React.Fragment key={item.value}>
<Box marginX={1} flexDirection="row" alignItems="center">
<Box marginX={1} flexDirection="row" alignItems="flex-start">
<Box minWidth={2} flexShrink={0}>
<Text
color={
@@ -989,33 +1020,49 @@ export function SettingsDialog({
{isActive ? '●' : ''}
</Text>
</Box>
<Box minWidth={50}>
<Text
color={
isActive ? theme.status.success : theme.text.primary
}
>
{item.label}
{scopeMessage && (
<Text color={theme.text.secondary}>
{' '}
{scopeMessage}
</Text>
)}
</Text>
</Box>
<Box minWidth={3} />
<Text
color={
isActive
? theme.status.success
: shouldBeGreyedOut
? theme.text.secondary
: theme.text.primary
}
<Box
flexDirection="row"
flexGrow={1}
minWidth={0}
alignItems="flex-start"
>
{displayValue}
</Text>
<Box
flexDirection="column"
width={maxLabelOrDescriptionWidth}
minWidth={0}
>
<Text
color={
isActive ? theme.status.success : theme.text.primary
}
>
{item.label}
{scopeMessage && (
<Text color={theme.text.secondary}>
{' '}
{scopeMessage}
</Text>
)}
</Text>
<Text color={theme.text.secondary} wrap="truncate">
{item.description ?? ''}
</Text>
</Box>
<Box minWidth={3} />
<Box flexShrink={0}>
<Text
color={
isActive
? theme.status.success
: shouldBeGreyedOut
? theme.text.secondary
: theme.text.primary
}
>
{displayValue}
</Text>
</Box>
</Box>
</Box>
<Box height={1} />
</React.Fragment>
@@ -0,0 +1,208 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render } from '../../test-utils/render.js';
import { Text } from 'ink';
import { StatusDisplay } from './StatusDisplay.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
// Mock child components to simplify testing
vi.mock('./ContextSummaryDisplay.js', () => ({
ContextSummaryDisplay: (props: { skillCount: number }) => (
<Text>Mock Context Summary Display (Skills: {props.skillCount})</Text>
),
}));
vi.mock('./HookStatusDisplay.js', () => ({
HookStatusDisplay: () => <Text>Mock Hook Status Display</Text>,
}));
// Create mock context providers
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
({
ctrlCPressedOnce: false,
warningMessage: null,
ctrlDPressedOnce: false,
showEscapePrompt: false,
queueErrorMessage: null,
activeHooks: [],
ideContextState: null,
geminiMdFileCount: 0,
contextFileNames: [],
...overrides,
}) as UIState;
const createMockConfig = (overrides = {}) => ({
getMcpClientManager: vi.fn().mockImplementation(() => ({
getBlockedMcpServers: vi.fn(() => []),
getMcpServers: vi.fn(() => ({})),
})),
getSkillManager: vi.fn().mockImplementation(() => ({
getSkills: vi.fn(() => ['skill1', 'skill2']),
})),
...overrides,
});
const createMockSettings = (merged = {}) => ({
merged: {
hooks: { notifications: true },
ui: { hideContextSummary: false },
...merged,
},
});
/* eslint-disable @typescript-eslint/no-explicit-any */
const renderStatusDisplay = (
props: { hideContextSummary: boolean } = { hideContextSummary: false },
uiState: UIState = createMockUIState(),
settings = createMockSettings(),
config = createMockConfig(),
) =>
render(
<ConfigContext.Provider value={config as any}>
<SettingsContext.Provider value={settings as any}>
<UIStateContext.Provider value={uiState}>
<StatusDisplay {...props} />
</UIStateContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>,
);
/* eslint-enable @typescript-eslint/no-explicit-any */
describe('StatusDisplay', () => {
const originalEnv = process.env;
afterEach(() => {
process.env = { ...originalEnv };
delete process.env['GEMINI_SYSTEM_MD'];
});
it('renders nothing by default if context summary is hidden via props', () => {
const { lastFrame } = renderStatusDisplay({ hideContextSummary: true });
expect(lastFrame()).toBe('');
});
it('renders ContextSummaryDisplay by default', () => {
const { lastFrame } = renderStatusDisplay();
expect(lastFrame()).toMatchSnapshot();
});
it('renders system md indicator if env var is set', () => {
process.env['GEMINI_SYSTEM_MD'] = 'true';
const { lastFrame } = renderStatusDisplay();
expect(lastFrame()).toMatchSnapshot();
});
it('prioritizes Ctrl+C prompt over everything else (except system md)', () => {
const uiState = createMockUIState({
ctrlCPressedOnce: true,
warningMessage: 'Warning',
activeHooks: [{ name: 'hook', eventName: 'event' }],
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders warning message', () => {
const uiState = createMockUIState({
warningMessage: 'This is a warning',
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('prioritizes warning over Ctrl+D', () => {
const uiState = createMockUIState({
warningMessage: 'Warning',
ctrlDPressedOnce: true,
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders Ctrl+D prompt', () => {
const uiState = createMockUIState({
ctrlDPressedOnce: true,
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders Escape prompt', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders Queue Error Message', () => {
const uiState = createMockUIState({
queueErrorMessage: 'Queue Error',
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders HookStatusDisplay when hooks are active', () => {
const uiState = createMockUIState({
activeHooks: [{ name: 'hook', eventName: 'event' }],
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('does NOT render HookStatusDisplay if notifications are disabled in settings', () => {
const uiState = createMockUIState({
activeHooks: [{ name: 'hook', eventName: 'event' }],
});
const settings = createMockSettings({
hooks: { notifications: false },
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
settings,
);
expect(lastFrame()).toMatchSnapshot();
});
it('hides ContextSummaryDisplay if configured in settings', () => {
const settings = createMockSettings({
ui: { hideContextSummary: true },
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
undefined,
settings,
);
expect(lastFrame()).toBe('');
});
});
@@ -0,0 +1,78 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
import { HookStatusDisplay } from './HookStatusDisplay.js';
interface StatusDisplayProps {
hideContextSummary: boolean;
}
export const StatusDisplay: React.FC<StatusDisplayProps> = ({
hideContextSummary,
}) => {
const uiState = useUIState();
const settings = useSettings();
const config = useConfig();
if (process.env['GEMINI_SYSTEM_MD']) {
return <Text color={theme.status.error}>|_| </Text>;
}
if (uiState.ctrlCPressedOnce) {
return (
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
);
}
if (uiState.warningMessage) {
return <Text color={theme.status.warning}>{uiState.warningMessage}</Text>;
}
if (uiState.ctrlDPressedOnce) {
return (
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text>
);
}
if (uiState.showEscapePrompt) {
return <Text color={theme.text.secondary}>Press Esc again to clear.</Text>;
}
if (uiState.queueErrorMessage) {
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
}
if (
uiState.activeHooks.length > 0 &&
(settings.merged.hooks?.notifications ?? true)
) {
return <HookStatusDisplay activeHooks={uiState.activeHooks} />;
}
if (!settings.merged.ui?.hideContextSummary && !hideContextSummary) {
return (
<ContextSummaryDisplay
ideContext={uiState.ideContextState}
geminiMdFileCount={uiState.geminiMdFileCount}
contextFileNames={uiState.contextFileNames}
mcpServers={config.getMcpClientManager()?.getMcpServers() ?? {}}
blockedMcpServers={
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
}
skillCount={config.getSkillManager().getSkills().length}
/>
);
}
return null;
};
@@ -51,7 +51,7 @@ const createMockSettings = (
path: '/workspace/settings.json',
},
true,
new Set(),
[],
);
describe('ThemeDialog Snapshots', () => {
@@ -0,0 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ContextSummaryDisplay /> > should not render empty parts 1`] = `" - 1 open file (ctrl+g to view)"`;
exports[`<ContextSummaryDisplay /> > should render on a single line on a wide screen 1`] = `" 1 open file (ctrl+g to view) | 1 GEMINI.md file | 1 MCP server | 1 skill"`;
exports[`<ContextSummaryDisplay /> > should render on multiple lines on a narrow screen 1`] = `
" - 1 open file (ctrl+g to view)
- 1 GEMINI.md file
- 1 MCP server
- 1 skill"
`;
@@ -1,8 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox Manual (gemini-pro) /model (100%)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox gemini-pro /model (100%)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...irectories/to/make/it/long no sandbox (see /docs) Manual (gemini-pro) /model (100% context left)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...irectories/to/make/it/long no sandbox (see /docs) gemini-pro /model (100% context left)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`;
@@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<HookStatusDisplay /> > should render a single executing hook 1`] = `"Executing Hook: test-hook"`;
exports[`<HookStatusDisplay /> > should render multiple executing hooks 1`] = `"Executing Hooks: h1, h2"`;
exports[`<HookStatusDisplay /> > should render sequential hook progress 1`] = `"Executing Hook: step (1/3)"`;
@@ -10,21 +10,29 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ▲ │
│ ● Preview Features (e.g., models) false
│ ● Preview Features (e.g., models) false
│ Enable preview features (e.g., preview models). │
│ │
│ Vim Mode false
│ Vim Mode false
│ Enable Vim keybindings │
│ │
│ Disable Auto Update false
│ Disable Auto Update false
│ Disable automatic updates │
│ │
│ Enable Prompt Completion false
│ Enable Prompt Completion false
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false
│ Debug Keystroke Logging false
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false
│ Enable Session Cleanup false
│ Enable automatic session cleanup │
│ │
│ Output Format Text
│ Output Format Text
│ The format of the CLI output. │
│ │
│ Hide Window Title false
│ Hide Window Title false
│ Hide the window title bar │
│ │
│ ▼ │
│ │
@@ -48,21 +56,29 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ▲ │
│ ● Preview Features (e.g., models) false
│ ● Preview Features (e.g., models) false
│ Enable preview features (e.g., preview models). │
│ │
│ Vim Mode true*
│ Vim Mode true*
│ Enable Vim keybindings │
│ │
│ Disable Auto Update false
│ Disable Auto Update false
│ Disable automatic updates │
│ │
│ Enable Prompt Completion false
│ Enable Prompt Completion false
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false
│ Debug Keystroke Logging false
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false
│ Enable Session Cleanup false
│ Enable automatic session cleanup │
│ │
│ Output Format Text
│ Output Format Text
│ The format of the CLI output. │
│ │
│ Hide Window Title false
│ Hide Window Title false
│ Hide the window title bar │
│ │
│ ▼ │
│ │
@@ -86,21 +102,29 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ▲ │
│ ● Preview Features (e.g., models) false
│ ● Preview Features (e.g., models) false
│ Enable preview features (e.g., preview models). │
│ │
│ Vim Mode false*
│ Vim Mode false*
│ Enable Vim keybindings │
│ │
│ Disable Auto Update false*
│ Disable Auto Update false*
│ Disable automatic updates │
│ │
│ Enable Prompt Completion false*
│ Enable Prompt Completion false*
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false*
│ Debug Keystroke Logging false*
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false
│ Enable Session Cleanup false
│ Enable automatic session cleanup │
│ │
│ Output Format Text
│ Output Format Text
│ The format of the CLI output. │
│ │
│ Hide Window Title false*
│ Hide Window Title false*
│ Hide the window title bar │
│ │
│ ▼ │
│ │
@@ -124,21 +148,29 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ▲ │
│ ● Preview Features (e.g., models) false
│ ● Preview Features (e.g., models) false
│ Enable preview features (e.g., preview models). │
│ │
│ Vim Mode false
│ Vim Mode false
│ Enable Vim keybindings │
│ │
│ Disable Auto Update false
│ Disable Auto Update false
│ Disable automatic updates │
│ │
│ Enable Prompt Completion false
│ Enable Prompt Completion false
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false
│ Debug Keystroke Logging false
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false
│ Enable Session Cleanup false
│ Enable automatic session cleanup │
│ │
│ Output Format Text
│ Output Format Text
│ The format of the CLI output. │
│ │
│ Hide Window Title false
│ Hide Window Title false
│ Hide the window title bar │
│ │
│ ▼ │
│ │
@@ -162,21 +194,29 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ▲ │
│ ● Preview Features (e.g., models) false
│ ● Preview Features (e.g., models) false
│ Enable preview features (e.g., preview models). │
│ │
│ Vim Mode false
│ Vim Mode false
│ Enable Vim keybindings │
│ │
│ Disable Auto Update false
│ Disable Auto Update false
│ Disable automatic updates │
│ │
│ Enable Prompt Completion false
│ Enable Prompt Completion false
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false
│ Debug Keystroke Logging false
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false
│ Enable Session Cleanup false
│ Enable automatic session cleanup │
│ │
│ Output Format Text
│ Output Format Text
│ The format of the CLI output. │
│ │
│ Hide Window Title false
│ Hide Window Title false
│ Hide the window title bar │
│ │
│ ▼ │
│ │
@@ -200,21 +240,29 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ▲ │
│ Preview Features (e.g., models) false
│ Preview Features (e.g., models) false
│ Enable preview features (e.g., preview models). │
│ │
│ Vim Mode false
│ Vim Mode false
│ Enable Vim keybindings │
│ │
│ Disable Auto Update false
│ Disable Auto Update false
│ Disable automatic updates │
│ │
│ Enable Prompt Completion false
│ Enable Prompt Completion false
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false
│ Debug Keystroke Logging false
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false
│ Enable Session Cleanup false
│ Enable automatic session cleanup │
│ │
│ Output Format Text
│ Output Format Text
│ The format of the CLI output. │
│ │
│ Hide Window Title false
│ Hide Window Title false
│ Hide the window title bar │
│ │
│ ▼ │
│ │
@@ -238,21 +286,29 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ▲ │
│ ● Preview Features (e.g., models) false
│ ● Preview Features (e.g., models) false
│ Enable preview features (e.g., preview models). │
│ │
│ Vim Mode false*
│ Vim Mode false*
│ Enable Vim keybindings │
│ │
│ Disable Auto Update true*
│ Disable Auto Update true*
│ Disable automatic updates │
│ │
│ Enable Prompt Completion false
│ Enable Prompt Completion false
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false
│ Debug Keystroke Logging false
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false
│ Enable Session Cleanup false
│ Enable automatic session cleanup │
│ │
│ Output Format Text
│ Output Format Text
│ The format of the CLI output. │
│ │
│ Hide Window Title false*
│ Hide Window Title false*
│ Hide the window title bar │
│ │
│ ▼ │
│ │
@@ -276,21 +332,29 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ▲ │
│ ● Preview Features (e.g., models) false
│ ● Preview Features (e.g., models) false
│ Enable preview features (e.g., preview models). │
│ │
│ Vim Mode false
│ Vim Mode false
│ Enable Vim keybindings │
│ │
│ Disable Auto Update false
│ Disable Auto Update false
│ Disable automatic updates │
│ │
│ Enable Prompt Completion false
│ Enable Prompt Completion false
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging false
│ Debug Keystroke Logging false
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false
│ Enable Session Cleanup false
│ Enable automatic session cleanup │
│ │
│ Output Format Text
│ Output Format Text
│ The format of the CLI output. │
│ │
│ Hide Window Title false
│ Hide Window Title false
│ Hide the window title bar │
│ │
│ ▼ │
│ │
@@ -314,21 +378,29 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ ▲ │
│ ● Preview Features (e.g., models) false
│ ● Preview Features (e.g., models) false
│ Enable preview features (e.g., preview models). │
│ │
│ Vim Mode true*
│ Vim Mode true*
│ Enable Vim keybindings │
│ │
│ Disable Auto Update true*
│ Disable Auto Update true*
│ Disable automatic updates │
│ │
│ Enable Prompt Completion true*
│ Enable Prompt Completion true*
│ Enable AI-powered prompt completion suggestions while typing. │
│ │
│ Debug Keystroke Logging true*
│ Debug Keystroke Logging true*
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false
│ Enable Session Cleanup false
│ Enable automatic session cleanup │
│ │
│ Output Format Text
│ Output Format Text
│ The format of the CLI output. │
│ │
│ Hide Window Title true*
│ Hide Window Title true*
│ Hide the window title bar │
│ │
│ ▼ │
│ │
@@ -0,0 +1,21 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `"Mock Context Summary Display (Skills: 2)"`;
exports[`StatusDisplay > prioritizes Ctrl+C prompt over everything else (except system md) 1`] = `"Press Ctrl+C again to exit."`;
exports[`StatusDisplay > prioritizes warning over Ctrl+D 1`] = `"Warning"`;
exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock Context Summary Display (Skills: 2)"`;
exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`;
exports[`StatusDisplay > renders Escape prompt 1`] = `"Press Esc again to clear."`;
exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = `"Mock Hook Status Display"`;
exports[`StatusDisplay > renders Queue Error Message 1`] = `"Queue Error"`;
exports[`StatusDisplay > renders system md indicator if env var is set 1`] = `"|⌐■_■|"`;
exports[`StatusDisplay > renders warning message 1`] = `"This is a warning"`;
+3
View File
@@ -28,3 +28,6 @@ export const TOOL_STATUS = {
// Maximum number of MCP resources to display per server before truncating
export const MAX_MCP_RESOURCES_TO_SHOW = 10;
export const WARNING_PROMPT_DURATION_MS = 1000;
export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
@@ -320,6 +320,111 @@ describe('KeypressContext', () => {
}),
);
});
it('should parse valid OSC 52 response', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
const base64Data = Buffer.from('Hello OSC 52').toString('base64');
const sequence = `\x1b]52;c;${base64Data}\x07`;
act(() => stdin.write(sequence));
await waitFor(() => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'paste',
paste: true,
sequence: 'Hello OSC 52',
}),
);
});
});
it('should handle split OSC 52 response', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
const base64Data = Buffer.from('Split Paste').toString('base64');
const sequence = `\x1b]52;c;${base64Data}\x07`;
// Split the sequence
const part1 = sequence.slice(0, 5);
const part2 = sequence.slice(5);
act(() => stdin.write(part1));
act(() => stdin.write(part2));
await waitFor(() => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'paste',
paste: true,
sequence: 'Split Paste',
}),
);
});
});
it('should handle OSC 52 response terminated by ESC \\', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
const base64Data = Buffer.from('Terminated by ST').toString('base64');
const sequence = `\x1b]52;c;${base64Data}\x1b\\`;
act(() => stdin.write(sequence));
await waitFor(() => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'paste',
paste: true,
sequence: 'Terminated by ST',
}),
);
});
});
it('should ignore unknown OSC sequences', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
const sequence = `\x1b]1337;File=name=Zm9vCg==\x07`;
act(() => stdin.write(sequence));
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(keyHandler).not.toHaveBeenCalled();
});
it('should ignore invalid OSC 52 format', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
const sequence = `\x1b]52;x;notbase64\x07`;
act(() => stdin.write(sequence));
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(keyHandler).not.toHaveBeenCalled();
});
});
describe('debug keystroke logging', () => {
@@ -317,12 +317,56 @@ function* emitKeys(
}
}
if (escaped && (ch === 'O' || ch === '[')) {
if (escaped && (ch === 'O' || ch === '[' || ch === ']')) {
// ANSI escape sequence
code = ch;
let modifier = 0;
if (ch === 'O') {
if (ch === ']') {
// OSC sequence
// ESC ] <params> ; <data> BEL
// ESC ] <params> ; <data> ESC \
let buffer = '';
// Read until BEL, `ESC \`, or timeout (empty string)
while (true) {
const next = yield;
if (next === '' || next === '\u0007') {
break;
} else if (next === ESC) {
const afterEsc = yield;
if (afterEsc === '' || afterEsc === '\\') {
break;
}
buffer += next + afterEsc;
continue;
}
buffer += next;
}
// Check for OSC 52 (Clipboard) response
// Format: 52;c;<base64> or 52;p;<base64>
const match = /^52;[cp];(.*)$/.exec(buffer);
if (match) {
try {
const base64Data = match[1];
const decoded = Buffer.from(base64Data, 'base64').toString('utf-8');
keypressHandler({
name: 'paste',
ctrl: false,
meta: false,
shift: false,
paste: true,
insertable: true,
sequence: decoded,
});
} catch (_e) {
debugLogger.log('Failed to decode OSC 52 clipboard data');
}
}
continue; // resume main loop
} else if (ch === 'O') {
// ESC O letter
// ESC O modifier letter
ch = yield;
@@ -14,6 +14,7 @@ import type {
LoopDetectionConfirmationRequest,
HistoryItemWithoutId,
StreamingState,
ActiveHook,
} from '../types.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { TextBuffer } from '../components/shared/text-buffer.js';
@@ -96,6 +97,7 @@ export interface UIState {
elapsedTime: number;
currentLoadingPhrase: string;
historyRemountKey: number;
activeHooks: ActiveHook[];
messageQueue: string[];
queueErrorMessage: string | null;
showAutoAcceptIndicator: ApprovalMode;
@@ -138,6 +140,7 @@ export interface UIState {
bannerVisible: boolean;
customDialog: React.ReactNode | null;
terminalBackgroundColor: TerminalBackgroundColor;
settingsNonce: number;
}
export const UIStateContext = createContext<UIState | null>(null);
@@ -4,7 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, type Mock, type MockInstance } from 'vitest';
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
type Mock,
type MockInstance,
} from 'vitest';
import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
@@ -118,7 +127,7 @@ describe('useFolderTrust', () => {
expect(addItem).not.toHaveBeenCalled();
});
it('should handle TRUST_FOLDER choice', async () => {
it('should handle TRUST_FOLDER choice and trigger restart', async () => {
isWorkspaceTrustedSpy.mockReturnValue({
isTrusted: undefined,
source: undefined,
@@ -148,12 +157,13 @@ describe('useFolderTrust', () => {
'/test/path',
TrustLevel.TRUST_FOLDER,
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
expect(result.current.isRestarting).toBe(true);
expect(result.current.isFolderTrustDialogOpen).toBe(true);
expect(onTrustChange).toHaveBeenLastCalledWith(true);
});
});
it('should handle TRUST_PARENT choice', () => {
it('should handle TRUST_PARENT choice and trigger restart', async () => {
isWorkspaceTrustedSpy.mockReturnValue({
isTrusted: undefined,
source: undefined,
@@ -162,19 +172,22 @@ describe('useFolderTrust', () => {
useFolderTrust(mockSettings, onTrustChange, addItem),
);
act(() => {
await act(async () => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_PARENT);
});
expect(mockTrustedFolders.setValue).toHaveBeenCalledWith(
'/test/path',
TrustLevel.TRUST_PARENT,
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
expect(onTrustChange).toHaveBeenLastCalledWith(true);
await waitFor(() => {
expect(mockTrustedFolders.setValue).toHaveBeenCalledWith(
'/test/path',
TrustLevel.TRUST_PARENT,
);
expect(result.current.isRestarting).toBe(true);
expect(result.current.isFolderTrustDialogOpen).toBe(true);
expect(onTrustChange).toHaveBeenLastCalledWith(true);
});
});
it('should handle DO_NOT_TRUST choice and trigger restart', () => {
it('should handle DO_NOT_TRUST choice and NOT trigger restart (implicit -> explicit)', async () => {
isWorkspaceTrustedSpy.mockReturnValue({
isTrusted: undefined,
source: undefined,
@@ -183,17 +196,19 @@ describe('useFolderTrust', () => {
useFolderTrust(mockSettings, onTrustChange, addItem),
);
act(() => {
await act(async () => {
result.current.handleFolderTrustSelect(FolderTrustChoice.DO_NOT_TRUST);
});
expect(mockTrustedFolders.setValue).toHaveBeenCalledWith(
'/test/path',
TrustLevel.DO_NOT_TRUST,
);
expect(onTrustChange).toHaveBeenLastCalledWith(false);
expect(result.current.isRestarting).toBe(true);
expect(result.current.isFolderTrustDialogOpen).toBe(true);
await waitFor(() => {
expect(mockTrustedFolders.setValue).toHaveBeenCalledWith(
'/test/path',
TrustLevel.DO_NOT_TRUST,
);
expect(onTrustChange).toHaveBeenLastCalledWith(false);
expect(result.current.isRestarting).toBe(false);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
});
it('should do nothing for default choice', async () => {
@@ -205,7 +220,7 @@ describe('useFolderTrust', () => {
useFolderTrust(mockSettings, onTrustChange, addItem),
);
act(() => {
await act(async () => {
result.current.handleFolderTrustSelect(
'invalid_choice' as FolderTrustChoice,
);
@@ -237,7 +252,7 @@ describe('useFolderTrust', () => {
expect(result.current.isTrusted).toBe(false);
});
act(() => {
await act(async () => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER);
});
@@ -247,21 +262,23 @@ describe('useFolderTrust', () => {
});
});
it('should not set isRestarting to true when trust status does not change', () => {
it('should not set isRestarting to true when trust status does not change (true -> true)', async () => {
isWorkspaceTrustedSpy.mockReturnValue({
isTrusted: undefined,
source: undefined,
isTrusted: true,
source: 'file',
});
const { result } = renderHook(() =>
useFolderTrust(mockSettings, onTrustChange, addItem),
);
act(() => {
await act(async () => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER);
});
expect(result.current.isRestarting).toBe(false);
expect(result.current.isFolderTrustDialogOpen).toBe(false); // Dialog should close
await waitFor(() => {
expect(result.current.isRestarting).toBe(false);
expect(result.current.isFolderTrustDialogOpen).toBe(false); // Dialog should close
});
});
it('should emit feedback on failure to set value', async () => {
+18 -22
View File
@@ -49,25 +49,17 @@ export const useFolderTrust = (
const handleFolderTrustSelect = useCallback(
(choice: FolderTrustChoice) => {
const trustedFolders = loadTrustedFolders();
const trustLevelMap: Record<FolderTrustChoice, TrustLevel> = {
[FolderTrustChoice.TRUST_FOLDER]: TrustLevel.TRUST_FOLDER,
[FolderTrustChoice.TRUST_PARENT]: TrustLevel.TRUST_PARENT,
[FolderTrustChoice.DO_NOT_TRUST]: TrustLevel.DO_NOT_TRUST,
};
const trustLevel = trustLevelMap[choice];
if (!trustLevel) return;
const cwd = process.cwd();
let trustLevel: TrustLevel;
const wasTrusted = isTrusted ?? true;
switch (choice) {
case FolderTrustChoice.TRUST_FOLDER:
trustLevel = TrustLevel.TRUST_FOLDER;
break;
case FolderTrustChoice.TRUST_PARENT:
trustLevel = TrustLevel.TRUST_PARENT;
break;
case FolderTrustChoice.DO_NOT_TRUST:
trustLevel = TrustLevel.DO_NOT_TRUST;
break;
default:
return;
}
const trustedFolders = loadTrustedFolders();
try {
trustedFolders.setValue(cwd, trustLevel);
@@ -86,11 +78,15 @@ export const useFolderTrust = (
const currentIsTrusted =
trustLevel === TrustLevel.TRUST_FOLDER ||
trustLevel === TrustLevel.TRUST_PARENT;
setIsTrusted(currentIsTrusted);
onTrustChange(currentIsTrusted);
const needsRestart = wasTrusted !== currentIsTrusted;
if (needsRestart) {
onTrustChange(currentIsTrusted);
setIsTrusted(currentIsTrusted);
// logic: we restart if the trust state *effectively* changes from the previous state.
// previous state was `isTrusted`. If undefined, we assume false (untrusted).
const wasTrusted = isTrusted ?? false;
if (wasTrusted !== currentIsTrusted) {
setIsRestarting(true);
setIsFolderTrustDialogOpen(true);
} else {
@@ -0,0 +1,234 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderHook } from '../../test-utils/render.js';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { useHookDisplayState } from './useHookDisplayState.js';
import {
coreEvents,
CoreEvent,
type HookStartPayload,
type HookEndPayload,
} from '@google/gemini-cli-core';
import { act } from 'react';
describe('useHookDisplayState', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
coreEvents.removeAllListeners(CoreEvent.HookStart);
coreEvents.removeAllListeners(CoreEvent.HookEnd);
});
it('should initialize with empty hooks', () => {
const { result } = renderHook(() => useHookDisplayState());
expect(result.current).toEqual([]);
});
it('should add a hook when HookStart event is emitted', () => {
const { result } = renderHook(() => useHookDisplayState());
const payload: HookStartPayload = {
hookName: 'test-hook',
eventName: 'before-agent',
hookIndex: 1,
totalHooks: 1,
};
act(() => {
coreEvents.emitHookStart(payload);
});
expect(result.current).toHaveLength(1);
expect(result.current[0]).toMatchObject({
name: 'test-hook',
eventName: 'before-agent',
});
});
it('should remove a hook immediately if duration > 1s', () => {
const { result } = renderHook(() => useHookDisplayState());
const startPayload: HookStartPayload = {
hookName: 'test-hook',
eventName: 'before-agent',
};
act(() => {
coreEvents.emitHookStart(startPayload);
});
// Advance time by 1.1 seconds
act(() => {
vi.advanceTimersByTime(1100);
});
const endPayload: HookEndPayload = {
hookName: 'test-hook',
eventName: 'before-agent',
success: true,
};
act(() => {
coreEvents.emitHookEnd(endPayload);
});
expect(result.current).toHaveLength(0);
});
it('should delay removal if duration < 1s', () => {
const { result } = renderHook(() => useHookDisplayState());
const startPayload: HookStartPayload = {
hookName: 'test-hook',
eventName: 'before-agent',
};
act(() => {
coreEvents.emitHookStart(startPayload);
});
// Advance time by only 100ms
act(() => {
vi.advanceTimersByTime(100);
});
const endPayload: HookEndPayload = {
hookName: 'test-hook',
eventName: 'before-agent',
success: true,
};
act(() => {
coreEvents.emitHookEnd(endPayload);
});
// Should still be present
expect(result.current).toHaveLength(1);
// Advance remaining time (900ms needed, let's go 950ms)
act(() => {
vi.advanceTimersByTime(950);
});
expect(result.current).toHaveLength(0);
});
it('should handle multiple hooks correctly', () => {
const { result } = renderHook(() => useHookDisplayState());
act(() => {
coreEvents.emitHookStart({ hookName: 'h1', eventName: 'e1' });
});
act(() => {
vi.advanceTimersByTime(500);
});
act(() => {
coreEvents.emitHookStart({ hookName: 'h2', eventName: 'e1' });
});
expect(result.current).toHaveLength(2);
// End h1 (total time 500ms -> needs 500ms delay)
act(() => {
coreEvents.emitHookEnd({
hookName: 'h1',
eventName: 'e1',
success: true,
});
});
// h1 still there
expect(result.current).toHaveLength(2);
// Advance 600ms. h1 should disappear. h2 has been running for 600ms.
act(() => {
vi.advanceTimersByTime(600);
});
expect(result.current).toHaveLength(1);
expect(result.current[0].name).toBe('h2');
// End h2 (total time 600ms -> needs 400ms delay)
act(() => {
coreEvents.emitHookEnd({
hookName: 'h2',
eventName: 'e1',
success: true,
});
});
expect(result.current).toHaveLength(1);
act(() => {
vi.advanceTimersByTime(500);
});
expect(result.current).toHaveLength(0);
});
it('should handle interleaved hooks with same name and event', () => {
const { result } = renderHook(() => useHookDisplayState());
const hook = { hookName: 'same-hook', eventName: 'same-event' };
// Start Hook 1 at t=0
act(() => {
coreEvents.emitHookStart(hook);
});
// Advance to t=500
act(() => {
vi.advanceTimersByTime(500);
});
// Start Hook 2 at t=500
act(() => {
coreEvents.emitHookStart(hook);
});
expect(result.current).toHaveLength(2);
expect(result.current[0].name).toBe('same-hook');
expect(result.current[1].name).toBe('same-hook');
// End Hook 1 at t=600 (Duration 600ms -> delay 400ms)
act(() => {
vi.advanceTimersByTime(100);
coreEvents.emitHookEnd({ ...hook, success: true });
});
// Both still visible (Hook 1 pending removal in 400ms)
expect(result.current).toHaveLength(2);
// Advance 400ms (t=1000). Hook 1 should be removed.
act(() => {
vi.advanceTimersByTime(400);
});
expect(result.current).toHaveLength(1);
// End Hook 2 at t=1100 (Duration: 1100 - 500 = 600ms -> delay 400ms)
act(() => {
vi.advanceTimersByTime(100);
coreEvents.emitHookEnd({ ...hook, success: true });
});
// Hook 2 still visible (pending removal in 400ms)
expect(result.current).toHaveLength(1);
// Advance 400ms (t=1500). Hook 2 should be removed.
act(() => {
vi.advanceTimersByTime(400);
});
expect(result.current).toHaveLength(0);
});
});
@@ -0,0 +1,104 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useRef } from 'react';
import {
coreEvents,
CoreEvent,
type HookStartPayload,
type HookEndPayload,
} from '@google/gemini-cli-core';
import { type ActiveHook } from '../types.js';
import { WARNING_PROMPT_DURATION_MS } from '../constants.js';
export const useHookDisplayState = () => {
const [activeHooks, setActiveHooks] = useState<ActiveHook[]>([]);
// Track start times independently of render state to calculate duration in event handlers
// Key: `${hookName}:${eventName}` -> Stack of StartTimes (FIFO)
const hookStartTimes = useRef<Map<string, number[]>>(new Map());
// Track active timeouts to clear them on unmount
const timeouts = useRef<Set<NodeJS.Timeout>>(new Set());
useEffect(() => {
const activeTimeouts = timeouts.current;
const startTimes = hookStartTimes.current;
const handleHookStart = (payload: HookStartPayload) => {
const key = `${payload.hookName}:${payload.eventName}`;
const now = Date.now();
// Add start time to ref
if (!startTimes.has(key)) {
startTimes.set(key, []);
}
startTimes.get(key)!.push(now);
setActiveHooks((prev) => [
...prev,
{
name: payload.hookName,
eventName: payload.eventName,
index: payload.hookIndex,
total: payload.totalHooks,
},
]);
};
const handleHookEnd = (payload: HookEndPayload) => {
const key = `${payload.hookName}:${payload.eventName}`;
const starts = startTimes.get(key);
const startTime = starts?.shift(); // Get the earliest start time for this hook type
// Cleanup empty arrays in map
if (starts && starts.length === 0) {
startTimes.delete(key);
}
const now = Date.now();
// Default to immediate removal if start time not found (defensive)
const elapsed = startTime ? now - startTime : WARNING_PROMPT_DURATION_MS;
const remaining = WARNING_PROMPT_DURATION_MS - elapsed;
const removeHook = () => {
setActiveHooks((prev) => {
const index = prev.findIndex(
(h) =>
h.name === payload.hookName && h.eventName === payload.eventName,
);
if (index === -1) return prev;
const newHooks = [...prev];
newHooks.splice(index, 1);
return newHooks;
});
};
if (remaining > 0) {
const timeoutId = setTimeout(() => {
removeHook();
activeTimeouts.delete(timeoutId);
}, remaining);
activeTimeouts.add(timeoutId);
} else {
removeHook();
}
};
coreEvents.on(CoreEvent.HookStart, handleHookStart);
coreEvents.on(CoreEvent.HookEnd, handleHookEnd);
return () => {
coreEvents.off(CoreEvent.HookStart, handleHookStart);
coreEvents.off(CoreEvent.HookEnd, handleHookEnd);
// Clear all pending timeouts
activeTimeouts.forEach(clearTimeout);
activeTimeouts.clear();
};
}, []);
return activeHooks;
};
+7
View File
@@ -418,3 +418,10 @@ export interface ConfirmationRequest {
export interface LoopDetectionConfirmationRequest {
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
}
export interface ActiveHook {
name: string;
eventName: string;
index?: number;
total?: number;
}
@@ -24,7 +24,7 @@ describe('colorizeCode', () => {
},
{ path: '', settings: {}, originalSettings: {} },
true,
new Set(),
[],
);
const result = colorizeCode({
@@ -198,7 +198,7 @@ Another paragraph.
},
{ path: '', settings: {}, originalSettings: {} },
true,
new Set(),
[],
);
const { lastFrame } = renderWithProviders(
@@ -85,18 +85,14 @@ export class TerminalCapabilityManager {
}
const cleanupOnExit = () => {
if (this.kittySupported) {
this.disableKittyProtocol();
}
if (this.modifyOtherKeysSupported) {
this.disableModifyOtherKeys();
}
if (this.bracketedPasteSupported) {
this.disableBracketedPaste();
}
// don't bother catching errors since if one write
// fails, the other probably will too
disableKittyKeyboardProtocol();
disableModifyOtherKeys();
disableBracketedPasteMode();
};
process.on('exit', () => cleanupOnExit);
process.on('SIGTERM', () => cleanupOnExit);
process.on('exit', cleanupOnExit);
process.on('SIGTERM', cleanupOnExit);
process.on('SIGINT', cleanupOnExit);
return new Promise((resolve) => {
@@ -234,13 +230,20 @@ export class TerminalCapabilityManager {
}
enableSupportedModes() {
if (this.kittySupported) {
this.enableKittyProtocol();
} else if (this.modifyOtherKeysSupported) {
this.enableModifyOtherKeys();
}
if (this.bracketedPasteSupported) {
this.enableBracketedPaste();
try {
if (this.kittySupported) {
enableKittyKeyboardProtocol();
this.kittyEnabled = true;
} else if (this.modifyOtherKeysSupported) {
enableModifyOtherKeys();
this.modifyOtherKeysEnabled = true;
}
if (this.bracketedPasteSupported) {
enableBracketedPasteMode();
this.bracketedPasteEnabled = true;
}
} catch (e) {
debugLogger.warn('Failed to enable keyboard protocols:', e);
}
}
@@ -264,72 +267,6 @@ export class TerminalCapabilityManager {
return this.bracketedPasteEnabled;
}
enableBracketedPaste(): void {
try {
if (this.bracketedPasteSupported) {
enableBracketedPasteMode();
this.bracketedPasteEnabled = true;
}
} catch (e) {
debugLogger.warn('Failed to enable bracketed paste mode:', e);
}
}
disableBracketedPaste(): void {
try {
if (this.bracketedPasteEnabled) {
disableBracketedPasteMode();
this.bracketedPasteEnabled = false;
}
} catch (e) {
debugLogger.warn('Failed to disable bracketed paste mode:', e);
}
}
enableKittyProtocol(): void {
try {
if (this.kittySupported) {
enableKittyKeyboardProtocol();
this.kittyEnabled = true;
}
} catch (e) {
debugLogger.warn('Failed to enable Kitty protocol:', e);
}
}
disableKittyProtocol(): void {
try {
if (this.kittyEnabled) {
disableKittyKeyboardProtocol();
this.kittyEnabled = false;
}
} catch (e) {
debugLogger.warn('Failed to disable Kitty protocol:', e);
}
}
enableModifyOtherKeys(): void {
try {
if (this.modifyOtherKeysSupported) {
enableModifyOtherKeys();
this.modifyOtherKeysEnabled = true;
}
} catch (e) {
debugLogger.warn('Failed to enable modifyOtherKeys protocol:', e);
}
}
disableModifyOtherKeys(): void {
try {
if (this.modifyOtherKeysEnabled) {
disableModifyOtherKeys();
this.modifyOtherKeysEnabled = false;
}
} catch (e) {
debugLogger.warn('Failed to disable modifyOtherKeys protocol:', e);
}
}
isModifyOtherKeysEnabled(): boolean {
return this.modifyOtherKeysEnabled;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.24.0-nightly.20251227.37be16243",
"version": "0.24.0-preview.2",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -8,6 +8,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
A2AClientManager,
type SendMessageResult,
createAdapterFetch,
} from './a2a-client-manager.js';
import type { AgentCard, Task } from '@a2a-js/sdk';
import type { AuthenticationHandler, Client } from '@a2a-js/sdk/client';
@@ -302,4 +303,42 @@ describe('A2AClientManager', () => {
).rejects.toThrow("Agent 'NonExistentAgent' not found.");
});
});
describe('createAdapterFetch', () => {
it('normalizes TASK_STATE_ enums to lower-case', async () => {
const baseFetch = vi
.fn()
.mockResolvedValue(
new Response(
JSON.stringify({ status: { state: 'TASK_STATE_WORKING' } }),
),
);
const adapter = createAdapterFetch(baseFetch as typeof fetch);
const response = await adapter('http://example.com', {
method: 'POST',
body: '{}',
});
const data = await response.json();
expect(data.status.state).toBe('working');
});
it('lowercases non-prefixed task states', async () => {
const baseFetch = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ status: { state: 'WORKING' } })),
);
const adapter = createAdapterFetch(baseFetch as typeof fetch);
const response = await adapter('http://example.com', {
method: 'POST',
body: '{}',
});
const data = await response.json();
expect(data.status.state).toBe('working');
});
});
});
+150 -1
View File
@@ -68,11 +68,15 @@ export class A2AClientManager {
throw new Error(`Agent with name '${name}' is already loaded.`);
}
let fetchImpl = fetch;
let fetchImpl: typeof fetch = fetch;
if (authHandler) {
fetchImpl = createAuthenticatingFetchWithRetry(fetch, authHandler);
}
// Wrap with custom adapter for ADK Reasoning Engine compatibility
// TODO: Remove this when a2a-js fixes compatibility
fetchImpl = createAdapterFetch(fetchImpl);
const resolver = new DefaultAgentCardResolver({ fetchImpl });
const options = ClientFactoryOptions.createFrom(
@@ -207,3 +211,148 @@ export class A2AClientManager {
}
}
}
/**
* Maps TaskState proto-JSON enums to lower-case strings.
*/
function mapTaskState(state: string | undefined): string | undefined {
if (!state) return state;
if (state.startsWith('TASK_STATE_')) {
return state.replace('TASK_STATE_', '').toLowerCase();
}
return state.toLowerCase();
}
/**
* Creates a fetch implementation that adapts standard A2A SDK requests to the
* proto-JSON dialect and endpoint shapes required by Vertex AI Agent Engine.
*/
export function createAdapterFetch(baseFetch: typeof fetch): typeof fetch {
return async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
const urlStr = input as string;
// 2. Dialect Mapping (Request)
let body = init?.body;
let isRpc = false;
let rpcId: string | number | undefined;
if (typeof body === 'string') {
try {
let jsonBody = JSON.parse(body);
// Unwrap JSON-RPC if present
if (jsonBody.jsonrpc === '2.0') {
isRpc = true;
rpcId = jsonBody.id;
jsonBody = jsonBody.params;
}
// Apply dialect translation to the message object
const message = jsonBody.message || jsonBody;
if (message && typeof message === 'object') {
// Role: user -> ROLE_USER, agent/model -> ROLE_AGENT
if (message.role === 'user') message.role = 'ROLE_USER';
if (message.role === 'agent' || message.role === 'model') {
message.role = 'ROLE_AGENT';
}
// Strip SDK-specific 'kind' field
delete message.kind;
// Map 'parts' to 'content' (Proto-JSON dialect often uses 'content' or typed parts)
// Also strip 'kind' from parts.
if (Array.isArray(message.parts)) {
message.content = message.parts.map(
(p: { kind?: string; text?: string }) => {
const { kind: _k, ...rest } = p;
// If it's a simple text part, ensure it matches { text: "..." }
if (p.kind === 'text') return { text: p.text };
return rest;
},
);
delete message.parts;
}
}
body = JSON.stringify(jsonBody);
} catch (error) {
debugLogger.debug(
'[A2AClientManager] Failed to parse request body for dialect translation:',
error,
);
// Non-JSON or parse error; let the baseFetch handle it.
}
}
const response = await baseFetch(urlStr, { ...init, body });
// Map response back
if (response.ok) {
try {
const responseData = await response.clone().json();
const result =
responseData.task || responseData.message || responseData;
// Restore 'kind' for the SDK and a2aUtils parsing
if (result && typeof result === 'object' && !result.kind) {
if (responseData.task || (result.id && result.status)) {
result.kind = 'task';
} else if (responseData.message || result.messageId) {
result.kind = 'message';
}
}
// Restore 'kind' on parts so extractMessageText works
if (result?.parts && Array.isArray(result.parts)) {
for (const part of result.parts) {
if (!part.kind) {
if (part.file) part.kind = 'file';
else if (part.data) part.kind = 'data';
else if (part.text) part.kind = 'text';
}
}
}
// Recursively restore 'kind' on artifact parts
if (result?.artifacts && Array.isArray(result.artifacts)) {
for (const artifact of result.artifacts) {
if (artifact.parts && Array.isArray(artifact.parts)) {
for (const part of artifact.parts) {
if (!part.kind) {
if (part.file) part.kind = 'file';
else if (part.data) part.kind = 'data';
else if (part.text) part.kind = 'text';
}
}
}
}
}
// Map Task States back to SDK expectations
if (result && typeof result === 'object' && result.status) {
result.status.state = mapTaskState(result.status.state);
}
if (isRpc) {
return new Response(
JSON.stringify({
jsonrpc: '2.0',
id: rpcId,
result,
}),
response,
);
}
return new Response(JSON.stringify(result), response);
} catch (_e) {
// Non-JSON response or unwrapping failure
}
}
return response;
};
}
+171
View File
@@ -0,0 +1,171 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
extractMessageText,
extractTaskText,
extractIdsFromResponse,
} from './a2aUtils.js';
import type { Message, Task, TextPart, DataPart, FilePart } from '@a2a-js/sdk';
describe('a2aUtils', () => {
describe('extractIdsFromResponse', () => {
it('should extract IDs from a message response', () => {
const message: Message = {
kind: 'message',
role: 'agent',
messageId: 'm1',
contextId: 'ctx-1',
taskId: 'task-1',
parts: [],
};
const result = extractIdsFromResponse(message);
expect(result).toEqual({ contextId: 'ctx-1', taskId: 'task-1' });
});
it('should extract IDs from an in-progress task response', () => {
const task: Task = {
id: 'task-2',
contextId: 'ctx-2',
kind: 'task',
status: { state: 'working' },
};
const result = extractIdsFromResponse(task);
expect(result).toEqual({ contextId: 'ctx-2', taskId: 'task-2' });
});
});
describe('extractMessageText', () => {
it('should extract text from simple text parts', () => {
const message: Message = {
kind: 'message',
role: 'user',
messageId: '1',
parts: [
{ kind: 'text', text: 'Hello' } as TextPart,
{ kind: 'text', text: 'World' } as TextPart,
],
};
expect(extractMessageText(message)).toBe('Hello\nWorld');
});
it('should extract data from data parts', () => {
const message: Message = {
kind: 'message',
role: 'user',
messageId: '1',
parts: [{ kind: 'data', data: { foo: 'bar' } } as DataPart],
};
expect(extractMessageText(message)).toBe('Data: {"foo":"bar"}');
});
it('should extract file info from file parts', () => {
const message: Message = {
kind: 'message',
role: 'user',
messageId: '1',
parts: [
{
kind: 'file',
file: {
name: 'test.txt',
uri: 'file://test.txt',
mimeType: 'text/plain',
},
} as FilePart,
{
kind: 'file',
file: {
uri: 'http://example.com/doc',
mimeType: 'application/pdf',
},
} as FilePart,
],
};
// The formatting logic in a2aUtils prefers name over uri
expect(extractMessageText(message)).toContain('File: test.txt');
expect(extractMessageText(message)).toContain(
'File: http://example.com/doc',
);
});
it('should handle mixed parts', () => {
const message: Message = {
kind: 'message',
role: 'user',
messageId: '1',
parts: [
{ kind: 'text', text: 'Here is data:' } as TextPart,
{ kind: 'data', data: { value: 123 } } as DataPart,
],
};
expect(extractMessageText(message)).toBe(
'Here is data:\nData: {"value":123}',
);
});
it('should return empty string for undefined or empty message', () => {
expect(extractMessageText(undefined)).toBe('');
expect(
extractMessageText({
kind: 'message',
role: 'user',
messageId: '1',
parts: [],
} as Message),
).toBe('');
});
});
describe('extractTaskText', () => {
it('should extract basic task info', () => {
const task: Task = {
id: 'task-1',
contextId: 'ctx-1',
kind: 'task',
status: {
state: 'working',
message: {
kind: 'message',
role: 'agent',
messageId: 'm1',
parts: [{ kind: 'text', text: 'Processing...' } as TextPart],
},
},
};
const result = extractTaskText(task);
expect(result).toContain('ID: task-1');
expect(result).toContain('State: working');
expect(result).toContain('Status Message: Processing...');
});
it('should extract artifacts', () => {
const task: Task = {
id: 'task-1',
contextId: 'ctx-1',
kind: 'task',
status: { state: 'completed' },
artifacts: [
{
artifactId: 'art-1',
name: 'Report',
parts: [{ kind: 'text', text: 'This is the report.' } as TextPart],
},
],
};
const result = extractTaskText(task);
expect(result).toContain('Artifacts:');
expect(result).toContain(' - Name: Report');
expect(result).toContain(' Content:');
expect(result).toContain(' This is the report.');
});
});
});
+142
View File
@@ -0,0 +1,142 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Message,
Task,
Part,
TextPart,
DataPart,
FilePart,
} from '@a2a-js/sdk';
/**
* Extracts a human-readable text representation from a Message object.
* Handles Text, Data (JSON), and File parts.
*/
export function extractMessageText(message: Message | undefined): string {
if (!message || !message.parts) {
return '';
}
const parts = message.parts
.map((part) => extractPartText(part))
.filter(Boolean);
return parts.join('\n');
}
/**
* Extracts text from a single Part.
*/
export function extractPartText(part: Part): string {
if (isTextPart(part)) {
return part.text;
}
if (isDataPart(part)) {
// Attempt to format known data types if metadata exists, otherwise JSON stringify
return `Data: ${JSON.stringify(part.data)}`;
}
if (isFilePart(part)) {
const fileData = part.file;
if (fileData.name) {
return `File: ${fileData.name}`;
}
if ('uri' in fileData && fileData.uri) {
return `File: ${fileData.uri}`;
}
return `File: [binary/unnamed]`;
}
return '';
}
/**
* Extracts a human-readable text summary from a Task object.
* Includes status, ID, and any artifact content.
*/
export function extractTaskText(task: Task): string {
let output = `ID: ${task.id}\n`;
output += `State: ${task.status.state}\n`;
// Status Message
const statusMessageText = extractMessageText(task.status.message);
if (statusMessageText) {
output += `Status Message: ${statusMessageText}\n`;
}
// Artifacts
if (task.artifacts && task.artifacts.length > 0) {
output += `Artifacts:\n`;
for (const artifact of task.artifacts) {
output += ` - Name: ${artifact.name}\n`;
if (artifact.parts && artifact.parts.length > 0) {
// Treat artifact parts as a message for extraction
const artifactContent = artifact.parts
.map((p) => extractPartText(p))
.filter(Boolean)
.join('\n');
if (artifactContent) {
// Indent content for readability
const indentedContent = artifactContent.replace(/^/gm, ' ');
output += ` Content:\n${indentedContent}\n`;
}
}
}
}
return output;
}
// Type Guards
function isTextPart(part: Part): part is TextPart {
return part.kind === 'text';
}
function isDataPart(part: Part): part is DataPart {
return part.kind === 'data';
}
function isFilePart(part: Part): part is FilePart {
return part.kind === 'file';
}
/**
* Extracts contextId and taskId from a Message or Task response.
* Follows the pattern from the A2A CLI sample to maintain conversational continuity.
*/
export function extractIdsFromResponse(result: Message | Task): {
contextId?: string;
taskId?: string;
} {
let contextId: string | undefined;
let taskId: string | undefined;
if (result.kind === 'message') {
taskId = result.taskId;
contextId = result.contextId;
} else if (result.kind === 'task') {
taskId = result.id;
contextId = result.contextId;
// If the task is in a final state (and not input-required), we clear the taskId
// so that the next interaction starts a fresh task (or keeps context without being bound to the old task).
if (
result.status &&
result.status.state !== 'input-required' &&
(result.status.state === 'completed' ||
result.status.state === 'failed' ||
result.status.state === 'canceled')
) {
taskId = undefined;
}
}
return { contextId, taskId };
}
@@ -13,6 +13,7 @@ import { LocalSubagentInvocation } from './local-invocation.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import { DELEGATE_TO_AGENT_TOOL_NAME } from '../tools/tool-names.js';
import { RemoteAgentInvocation } from './remote-invocation.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
vi.mock('./local-invocation.js', () => ({
@@ -23,6 +24,15 @@ vi.mock('./local-invocation.js', () => ({
})),
}));
vi.mock('./remote-invocation.js', () => ({
RemoteAgentInvocation: vi.fn().mockImplementation(() => ({
execute: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'Remote Success' }],
}),
shouldConfirmExecute: vi.fn().mockResolvedValue(true),
})),
}));
describe('DelegateToAgentTool', () => {
let registry: AgentRegistry;
let config: Config;
@@ -45,6 +55,18 @@ describe('DelegateToAgentTool', () => {
toolConfig: { tools: [] },
};
const mockRemoteAgentDef: AgentDefinition = {
kind: 'remote',
name: 'remote_agent',
description: 'A remote agent',
agentCardUrl: 'https://example.com/agent.json',
inputConfig: {
inputs: {
query: { type: 'string', description: 'Query', required: true },
},
},
};
beforeEach(() => {
config = {
getDebugMode: () => false,
@@ -58,6 +80,8 @@ describe('DelegateToAgentTool', () => {
// Manually register the mock agent (bypassing protected method for testing)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(registry as any).agents.set(mockAgentDef.name, mockAgentDef);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(registry as any).agents.set(mockRemoteAgentDef.name, mockRemoteAgentDef);
messageBus = createMockMessageBus();
@@ -176,4 +200,23 @@ describe('DelegateToAgentTool', () => {
}),
);
});
it('should delegate to remote agent correctly', async () => {
const invocation = tool.build({
agent_name: 'remote_agent',
query: 'hello remote',
});
const result = await invocation.execute(new AbortController().signal);
expect(result).toEqual({
content: [{ type: 'text', text: 'Remote Success' }],
});
expect(RemoteAgentInvocation).toHaveBeenCalledWith(
mockRemoteAgentDef,
{ query: 'hello remote' },
messageBus,
'remote_agent',
'remote_agent',
);
});
});
+1 -100
View File
@@ -57,12 +57,8 @@ import { AgentTerminateMode } from './types.js';
import type { AnyDeclarativeTool, AnyToolInvocation } from '../tools/tools.js';
import { CompressionStatus } from '../core/turn.js';
import { ChatCompressionService } from '../services/chatCompressionService.js';
import type {
ModelConfigKey,
ResolvedModelConfig,
} from '../services/modelConfigService.js';
import type { ModelConfigKey } from '../services/modelConfigService.js';
import { getModelConfigAlias } from './registry.js';
import type { ModelRouterService } from '../routing/modelRouterService.js';
const {
mockSendMessageStream,
@@ -1196,101 +1192,6 @@ describe('LocalAgentExecutor', () => {
});
});
describe('Model Routing', () => {
it('should use model routing when the agent model is "auto"', async () => {
const definition = createTestDefinition();
definition.modelConfig.model = 'auto';
const mockRouter = {
route: vi.fn().mockResolvedValue({
model: 'routed-model',
metadata: { source: 'test', reasoning: 'test' },
}),
};
vi.spyOn(mockConfig, 'getModelRouterService').mockReturnValue(
mockRouter as unknown as ModelRouterService,
);
// Mock resolved config to return 'auto'
vi.spyOn(
mockConfig.modelConfigService,
'getResolvedConfig',
).mockReturnValue({
model: 'auto',
generateContentConfig: {},
} as unknown as ResolvedModelConfig);
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
mockModelResponse([
{
name: TASK_COMPLETE_TOOL_NAME,
args: { finalResult: 'done' },
id: 'call1',
},
]);
await executor.run({ goal: 'test' }, signal);
expect(mockRouter.route).toHaveBeenCalled();
expect(mockSendMessageStream).toHaveBeenCalledWith(
expect.objectContaining({ model: 'routed-model' }),
expect.any(Array),
expect.any(String),
expect.any(AbortSignal),
);
});
it('should NOT use model routing when the agent model is NOT "auto"', async () => {
const definition = createTestDefinition();
definition.modelConfig.model = 'concrete-model';
const mockRouter = {
route: vi.fn(),
};
vi.spyOn(mockConfig, 'getModelRouterService').mockReturnValue(
mockRouter as unknown as ModelRouterService,
);
// Mock resolved config to return 'concrete-model'
vi.spyOn(
mockConfig.modelConfigService,
'getResolvedConfig',
).mockReturnValue({
model: 'concrete-model',
generateContentConfig: {},
} as unknown as ResolvedModelConfig);
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
mockModelResponse([
{
name: TASK_COMPLETE_TOOL_NAME,
args: { finalResult: 'done' },
id: 'call1',
},
]);
await executor.run({ goal: 'test' }, signal);
expect(mockRouter.route).not.toHaveBeenCalled();
expect(mockSendMessageStream).toHaveBeenCalledWith(
expect.objectContaining({ model: 'concrete-model' }),
expect.any(Array),
expect.any(String),
expect.any(AbortSignal),
);
});
});
describe('run (Termination Conditions)', () => {
const mockWorkResponse = (id: string) => {
mockModelResponse([{ name: LS_TOOL_NAME, args: { path: '.' }, id }]);
+1 -28
View File
@@ -40,8 +40,6 @@ import type {
} from './types.js';
import { AgentTerminateMode } from './types.js';
import { templateString } from './utils.js';
import { isAutoModel } from '../config/models.js';
import type { RoutingContext } from '../routing/routingStrategy.js';
import { parseThought } from '../utils/thoughtUtils.js';
import { type z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
@@ -591,34 +589,9 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
signal: AbortSignal,
promptId: string,
): Promise<{ functionCalls: FunctionCall[]; textResponse: string }> {
const modelConfigAlias = getModelConfigAlias(this.definition);
// Resolve the model config early to get the concrete model string (which may be `auto`).
const resolvedConfig =
this.runtimeContext.modelConfigService.getResolvedConfig({
model: modelConfigAlias,
overrideScope: this.definition.name,
});
const requestedModel = resolvedConfig.model;
let modelToUse: string;
if (isAutoModel(requestedModel)) {
const routingContext: RoutingContext = {
history: chat.getHistory(/*curated=*/ true),
request: message.parts || [],
signal,
requestedModel,
};
const router = this.runtimeContext.getModelRouterService();
const decision = await router.route(routingContext);
modelToUse = decision.model;
} else {
modelToUse = requestedModel;
}
const responseStream = await chat.sendMessageStream(
{
model: modelToUse,
model: getModelConfigAlias(this.definition),
overrideScope: this.definition.name,
},
message.parts || [],
-36
View File
@@ -244,42 +244,6 @@ describe('AgentRegistry', () => {
});
describe('registration logic', () => {
it('should register runtime overrides when the model is "auto"', async () => {
const autoAgent: LocalAgentDefinition = {
...MOCK_AGENT_V1,
name: 'AutoAgent',
modelConfig: { ...MOCK_AGENT_V1.modelConfig, model: 'auto' },
};
const registerOverrideSpy = vi.spyOn(
mockConfig.modelConfigService,
'registerRuntimeModelOverride',
);
await registry.testRegisterAgent(autoAgent);
// Should register two overrides: one for the alias and one for the agent name (scope)
expect(registerOverrideSpy).toHaveBeenCalledTimes(2);
// Check alias override
expect(registerOverrideSpy).toHaveBeenCalledWith(
expect.objectContaining({
match: { model: getModelConfigAlias(autoAgent) },
modelConfig: expect.objectContaining({ model: 'auto' }),
}),
);
// Check scope override
expect(registerOverrideSpy).toHaveBeenCalledWith(
expect.objectContaining({
match: { overrideScope: autoAgent.name },
modelConfig: expect.objectContaining({
generateContentConfig: expect.any(Object),
}),
}),
);
});
it('should register a valid agent definition', async () => {
await registry.testRegisterAgent(MOCK_AGENT_V1);
expect(registry.getDefinition('MockAgent')).toEqual(MOCK_AGENT_V1);
+15 -38
View File
@@ -19,7 +19,6 @@ import {
GEMINI_MODEL_ALIAS_AUTO,
PREVIEW_GEMINI_FLASH_MODEL,
isPreviewModel,
isAutoModel,
} from '../config/models.js';
import type { ModelConfigAlias } from '../services/modelConfigService.js';
@@ -207,46 +206,24 @@ export class AgentRegistry {
model = this.config.getModel();
}
const generateContentConfig = {
temperature: modelConfig.temp,
topP: modelConfig.top_p,
thinkingConfig: {
includeThoughts: true,
thinkingBudget: modelConfig.thinkingBudget ?? -1,
const runtimeAlias: ModelConfigAlias = {
modelConfig: {
model,
generateContentConfig: {
temperature: modelConfig.temp,
topP: modelConfig.top_p,
thinkingConfig: {
includeThoughts: true,
thinkingBudget: modelConfig.thinkingBudget ?? -1,
},
},
},
};
if (isAutoModel(model)) {
this.config.modelConfigService.registerRuntimeModelOverride({
match: {
model: getModelConfigAlias(definition),
},
modelConfig: {
model,
generateContentConfig,
},
});
this.config.modelConfigService.registerRuntimeModelOverride({
match: {
overrideScope: definition.name,
},
modelConfig: {
generateContentConfig,
},
});
} else {
const runtimeAlias: ModelConfigAlias = {
modelConfig: {
model,
generateContentConfig,
},
};
this.config.modelConfigService.registerRuntimeModelConfig(
getModelConfigAlias(definition),
runtimeAlias,
);
}
this.config.modelConfigService.registerRuntimeModelConfig(
getModelConfigAlias(definition),
runtimeAlias,
);
}
/**
@@ -4,55 +4,310 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import type { ToolCallConfirmationDetails } from '../tools/tools.js';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { RemoteAgentInvocation } from './remote-invocation.js';
import { A2AClientManager } from './a2a-client-manager.js';
import type { RemoteAgentDefinition } from './types.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
class TestableRemoteAgentInvocation extends RemoteAgentInvocation {
override async getConfirmationDetails(
abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
return super.getConfirmationDetails(abortSignal);
}
}
// Mock A2AClientManager
vi.mock('./a2a-client-manager.js', () => {
const A2AClientManager = {
getInstance: vi.fn(),
};
return { A2AClientManager };
});
describe('RemoteAgentInvocation', () => {
const mockDefinition: RemoteAgentDefinition = {
name: 'test-agent',
kind: 'remote',
name: 'test-remote-agent',
description: 'A test remote agent',
displayName: 'Test Remote Agent',
agentCardUrl: 'https://example.com/agent-card',
agentCardUrl: 'http://test-agent/card',
displayName: 'Test Agent',
description: 'A test agent',
inputConfig: {
inputs: {},
},
};
const mockClientManager = {
getClient: vi.fn(),
loadAgent: vi.fn(),
sendMessage: vi.fn(),
};
const mockMessageBus = createMockMessageBus();
it('should be instantiated with correct params', () => {
const invocation = new RemoteAgentInvocation(
mockDefinition,
{},
mockMessageBus,
);
expect(invocation).toBeDefined();
expect(invocation.getDescription()).toBe(
'Calling remote agent Test Remote Agent',
);
beforeEach(() => {
vi.clearAllMocks();
(A2AClientManager.getInstance as Mock).mockReturnValue(mockClientManager);
(
RemoteAgentInvocation as unknown as {
sessionState?: Map<string, { contextId?: string; taskId?: string }>;
}
).sessionState?.clear();
});
it('should return false for confirmation details (not yet implemented)', async () => {
const invocation = new TestableRemoteAgentInvocation(
mockDefinition,
{},
mockMessageBus,
);
const details = await invocation.getConfirmationDetails(
new AbortController().signal,
);
expect(details).toBe(false);
afterEach(() => {
vi.restoreAllMocks();
});
describe('Constructor Validation', () => {
it('accepts valid input with string query', () => {
expect(() => {
new RemoteAgentInvocation(
mockDefinition,
{ query: 'valid' },
mockMessageBus,
);
}).not.toThrow();
});
it('throws if query is missing', () => {
expect(() => {
new RemoteAgentInvocation(mockDefinition, {}, mockMessageBus);
}).toThrow("requires a string 'query' input");
});
it('throws if query is not a string', () => {
expect(() => {
new RemoteAgentInvocation(
mockDefinition,
{ query: 123 },
mockMessageBus,
);
}).toThrow("requires a string 'query' input");
});
});
describe('Execution Logic', () => {
it('should lazy load the agent with ADCHandler if not present', async () => {
mockClientManager.getClient.mockReturnValue(undefined);
mockClientManager.sendMessage.mockResolvedValue({
kind: 'message',
messageId: 'msg-1',
role: 'agent',
parts: [{ kind: 'text', text: 'Hello' }],
});
const invocation = new RemoteAgentInvocation(
mockDefinition,
{
query: 'hi',
},
mockMessageBus,
);
await invocation.execute(new AbortController().signal);
expect(mockClientManager.loadAgent).toHaveBeenCalledWith(
'test-agent',
'http://test-agent/card',
expect.objectContaining({
headers: expect.any(Function),
shouldRetryWithHeaders: expect.any(Function),
}),
);
});
it('should not load the agent if already present', async () => {
mockClientManager.getClient.mockReturnValue({});
mockClientManager.sendMessage.mockResolvedValue({
kind: 'message',
messageId: 'msg-1',
role: 'agent',
parts: [{ kind: 'text', text: 'Hello' }],
});
const invocation = new RemoteAgentInvocation(
mockDefinition,
{
query: 'hi',
},
mockMessageBus,
);
await invocation.execute(new AbortController().signal);
expect(mockClientManager.loadAgent).not.toHaveBeenCalled();
});
it('should persist contextId and taskId across invocations', async () => {
mockClientManager.getClient.mockReturnValue({});
// First call return values
mockClientManager.sendMessage.mockResolvedValueOnce({
kind: 'message',
messageId: 'msg-1',
role: 'agent',
parts: [{ kind: 'text', text: 'Response 1' }],
contextId: 'ctx-1',
taskId: 'task-1',
});
const invocation1 = new RemoteAgentInvocation(
mockDefinition,
{
query: 'first',
},
mockMessageBus,
);
// Execute first time
const result1 = await invocation1.execute(new AbortController().signal);
expect(result1.returnDisplay).toBe('Response 1');
expect(mockClientManager.sendMessage).toHaveBeenLastCalledWith(
'test-agent',
'first',
{ contextId: undefined, taskId: undefined },
);
// Prepare for second call with simulated state persistence
mockClientManager.sendMessage.mockResolvedValueOnce({
kind: 'message',
messageId: 'msg-2',
role: 'agent',
parts: [{ kind: 'text', text: 'Response 2' }],
contextId: 'ctx-1',
taskId: 'task-2',
});
const invocation2 = new RemoteAgentInvocation(
mockDefinition,
{
query: 'second',
},
mockMessageBus,
);
const result2 = await invocation2.execute(new AbortController().signal);
expect(result2.returnDisplay).toBe('Response 2');
expect(mockClientManager.sendMessage).toHaveBeenLastCalledWith(
'test-agent',
'second',
{ contextId: 'ctx-1', taskId: 'task-1' }, // Used state from first call
);
// Third call: Task completes
mockClientManager.sendMessage.mockResolvedValueOnce({
kind: 'task',
id: 'task-2',
contextId: 'ctx-1',
status: { state: 'completed', message: undefined },
artifacts: [],
history: [],
});
const invocation3 = new RemoteAgentInvocation(
mockDefinition,
{
query: 'third',
},
mockMessageBus,
);
await invocation3.execute(new AbortController().signal);
// Fourth call: Should start new task (taskId undefined)
mockClientManager.sendMessage.mockResolvedValueOnce({
kind: 'message',
messageId: 'msg-3',
role: 'agent',
parts: [{ kind: 'text', text: 'New Task' }],
});
const invocation4 = new RemoteAgentInvocation(
mockDefinition,
{
query: 'fourth',
},
mockMessageBus,
);
await invocation4.execute(new AbortController().signal);
expect(mockClientManager.sendMessage).toHaveBeenLastCalledWith(
'test-agent',
'fourth',
{ contextId: 'ctx-1', taskId: undefined }, // taskId cleared!
);
});
it('should handle errors gracefully', async () => {
mockClientManager.getClient.mockReturnValue({});
mockClientManager.sendMessage.mockRejectedValue(
new Error('Network error'),
);
const invocation = new RemoteAgentInvocation(
mockDefinition,
{
query: 'hi',
},
mockMessageBus,
);
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.error?.message).toContain('Network error');
expect(result.returnDisplay).toContain('Network error');
});
it('should use a2a helpers for extracting text', async () => {
mockClientManager.getClient.mockReturnValue({});
// Mock a complex message part that needs extraction
mockClientManager.sendMessage.mockResolvedValue({
kind: 'message',
messageId: 'msg-1',
role: 'agent',
parts: [
{ kind: 'text', text: 'Extracted text' },
{ kind: 'data', data: { foo: 'bar' } },
],
});
const invocation = new RemoteAgentInvocation(
mockDefinition,
{
query: 'hi',
},
mockMessageBus,
);
const result = await invocation.execute(new AbortController().signal);
// Just check that text is present, exact formatting depends on helper
expect(result.returnDisplay).toContain('Extracted text');
});
});
describe('Confirmations', () => {
it('should return info confirmation details', async () => {
const invocation = new RemoteAgentInvocation(
mockDefinition,
{
query: 'hi',
},
mockMessageBus,
);
// @ts-expect-error - getConfirmationDetails is protected
const confirmation = await invocation.getConfirmationDetails(
new AbortController().signal,
);
expect(confirmation).not.toBe(false);
if (
confirmation &&
typeof confirmation === 'object' &&
confirmation.type === 'info'
) {
expect(confirmation.title).toContain('Test Agent');
expect(confirmation.prompt).toContain('http://test-agent/card');
} else {
throw new Error('Expected confirmation to be of type info');
}
});
});
});
+142 -7
View File
@@ -9,8 +9,54 @@ import {
type ToolResult,
type ToolCallConfirmationDetails,
} from '../tools/tools.js';
import type { AgentInputs, RemoteAgentDefinition } from './types.js';
import type {
RemoteAgentInputs,
RemoteAgentDefinition,
AgentInputs,
} from './types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { A2AClientManager } from './a2a-client-manager.js';
import {
extractMessageText,
extractTaskText,
extractIdsFromResponse,
} from './a2aUtils.js';
import { GoogleAuth } from 'google-auth-library';
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
import { debugLogger } from '../utils/debugLogger.js';
/**
* Authentication handler implementation using Google Application Default Credentials (ADC).
*/
export class ADCHandler implements AuthenticationHandler {
private auth = new GoogleAuth({
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
});
async headers(): Promise<Record<string, string>> {
try {
const client = await this.auth.getClient();
const token = await client.getAccessToken();
if (token.token) {
return { Authorization: `Bearer ${token.token}` };
}
throw new Error('Failed to retrieve ADC access token.');
} catch (e) {
const errorMessage = `Failed to get ADC token: ${
e instanceof Error ? e.message : String(e)
}`;
debugLogger.log('ERROR', errorMessage);
throw new Error(errorMessage);
}
}
async shouldRetryWithHeaders(
_response: unknown,
): Promise<Record<string, string> | undefined> {
// For ADC, we usually just re-fetch the token if needed.
return this.headers();
}
}
/**
* A tool invocation that proxies to a remote A2A agent.
@@ -19,9 +65,22 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
* invokes the configured A2A tool.
*/
export class RemoteAgentInvocation extends BaseToolInvocation<
AgentInputs,
RemoteAgentInputs,
ToolResult
> {
// Persist state across ephemeral invocation instances.
private static readonly sessionState = new Map<
string,
{ contextId?: string; taskId?: string }
>();
// State for the ongoing conversation with the remote agent
private contextId: string | undefined;
private taskId: string | undefined;
// TODO: See if we can reuse the singleton from AppContainer or similar, but for now use getInstance directly
// as per the current pattern in the codebase.
private readonly clientManager = A2AClientManager.getInstance();
private readonly authHandler = new ADCHandler();
constructor(
private readonly definition: RemoteAgentDefinition,
params: AgentInputs,
@@ -29,8 +88,15 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
_toolName?: string,
_toolDisplayName?: string,
) {
const query = params['query'];
if (typeof query !== 'string') {
throw new Error(
`Remote agent '${definition.name}' requires a string 'query' input.`,
);
}
// Safe to pass strict object to super
super(
params,
{ query },
messageBus,
_toolName ?? definition.name,
_toolDisplayName ?? definition.displayName,
@@ -44,12 +110,81 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
protected override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
// TODO: Implement confirmation logic for remote agents.
return false;
// For now, always require confirmation for remote agents until we have a policy system for them.
return {
type: 'info',
title: `Call Remote Agent: ${this.definition.displayName ?? this.definition.name}`,
prompt: `This will send a message to the external agent at ${this.definition.agentCardUrl}.`,
onConfirm: async () => {}, // No-op for now, just informational
};
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
// TODO: Implement remote agent invocation logic.
throw new Error(`Remote agent invocation not implemented.`);
// 1. Ensure the agent is loaded (cached by manager)
// We assume the user has provided an access token via some mechanism (TODO),
// or we rely on ADC.
try {
const priorState = RemoteAgentInvocation.sessionState.get(
this.definition.name,
);
if (priorState) {
this.contextId = priorState.contextId;
this.taskId = priorState.taskId;
}
if (!this.clientManager.getClient(this.definition.name)) {
await this.clientManager.loadAgent(
this.definition.name,
this.definition.agentCardUrl,
this.authHandler,
);
}
const message = this.params.query;
const response = await this.clientManager.sendMessage(
this.definition.name,
message,
{
contextId: this.contextId,
taskId: this.taskId,
},
);
// Extracts IDs, taskID will be undefined if the task is completed/failed/canceled.
const { contextId, taskId } = extractIdsFromResponse(response);
this.contextId = contextId ?? this.contextId;
this.taskId = taskId;
RemoteAgentInvocation.sessionState.set(this.definition.name, {
contextId: this.contextId,
taskId: this.taskId,
});
// Extract the output text
const resultData = response;
let outputText = '';
if (resultData.kind === 'message') {
outputText = extractMessageText(resultData);
} else if (resultData.kind === 'task') {
outputText = extractTaskText(resultData);
} else {
outputText = JSON.stringify(resultData);
}
return {
llmContent: [{ text: outputText }],
returnDisplay: outputText,
};
} catch (error: unknown) {
const errorMessage = `Error calling remote agent: ${error instanceof Error ? error.message : String(error)}`;
return {
llmContent: [{ text: errorMessage }],
returnDisplay: errorMessage,
error: { message: errorMessage },
};
}
}
}
+5
View File
@@ -38,6 +38,11 @@ export interface OutputObject {
*/
export type AgentInputs = Record<string, unknown>;
/**
* Simplified input structure for Remote Agents, which consumes a single string query.
*/
export type RemoteAgentInputs = { query: string };
/**
* Structured events emitted during subagent execution for user observability.
*/

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