mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 05:31:02 -07:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df72e3af3d | |||
| 334b813d81 | |||
| 314f67a326 | |||
| 0f0d1d8fc0 | |||
| cb15a238fe | |||
| 59a18e710d | |||
| 5f027cb63a | |||
| 4086abf375 | |||
| 8f5bf33eac | |||
| 7eeb7bd74c | |||
| 96b9be3ec4 | |||
| 1e31427da8 | |||
| 7feb2f8f42 | |||
| 2122604b32 | |||
| d4b4aede2f | |||
| 4b5c044272 | |||
| 2fe45834dd | |||
| 56092bd782 | |||
| c31f05356a | |||
| 61dbab03e0 | |||
| 86b5995f12 | |||
| d2849fda8a | |||
| a61fb058b7 | |||
| 7edd803034 | |||
| 0c54136244 | |||
| cce4574143 | |||
| 9172e28315 | |||
| 6f4b2ad0b9 | |||
| 9a3ff6510f | |||
| 4c67eef0f2 | |||
| 7d4f97de7a | |||
| cbd2eee9c4 | |||
| 384fb6a465 | |||
| 2cb33b2f76 |
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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() }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}."
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}'"
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
@@ -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'}}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+12
-12
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>"',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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] : []),
|
||||
|
||||
@@ -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(),
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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"`;
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }]);
|
||||
|
||||
@@ -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 || [],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user