diff --git a/.github/actions/create-pull-request/action.yml b/.github/actions/create-pull-request/action.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b9fdebf79a9f6fbab5eb396461d6ae9491ea88e5
--- /dev/null
+++ b/.github/actions/create-pull-request/action.yml
@@ -0,0 +1,55 @@
+name: 'Create Pull Request'
+description: 'Creates a pull request.'
+
+inputs:
+ branch-name:
+ description: 'The name of the branch to create the PR from.'
+ required: true
+ pr-title:
+ description: 'The title of the pull request.'
+ required: true
+ pr-body:
+ description: 'The body of the pull request.'
+ required: true
+ base-branch:
+ description: 'The branch to merge into.'
+ required: true
+ default: 'main'
+ github-token:
+ description: 'The GitHub token to use for creating the pull request.'
+ required: true
+ dry-run:
+ description: 'Whether to run in dry-run mode.'
+ required: false
+ default: 'false'
+ working-directory:
+ description: 'The working directory to run the commands in.'
+ required: false
+ default: '.'
+
+runs:
+ using: 'composite'
+ steps:
+ - name: 'Creates a Pull Request'
+ 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."
+ exit 1
+ fi
+ PR_URL=$(gh pr create \
+ --title "${INPUTS_PR_TITLE}" \
+ --body "${INPUTS_PR_BODY}" \
+ --base "${INPUTS_BASE_BRANCH}" \
+ --head "${INPUTS_BRANCH_NAME}" \
+ --fill)
+ gh pr merge "$PR_URL" --auto
diff --git a/.github/actions/post-coverage-comment/action.yml b/.github/actions/post-coverage-comment/action.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6862e6be1f7b70b77f65731c41802fbaccf241ba
--- /dev/null
+++ b/.github/actions/post-coverage-comment/action.yml
@@ -0,0 +1,114 @@
+name: 'Post Coverage Comment Action'
+description: 'Prepares and posts a code coverage comment to a PR.'
+
+inputs:
+ cli_json_file:
+ description: 'Path to CLI coverage-summary.json'
+ required: true
+ core_json_file:
+ description: 'Path to Core coverage-summary.json'
+ required: true
+ cli_full_text_summary_file:
+ description: 'Path to CLI full-text-summary.txt'
+ required: true
+ core_full_text_summary_file:
+ description: 'Path to Core full-text-summary.txt'
+ required: true
+ node_version:
+ description: 'Node.js version for context in messages'
+ required: true
+ os:
+ description: 'The os for context in messages'
+ required: true
+ github_token:
+ description: 'GitHub token for posting comments'
+ required: true
+
+runs:
+ using: 'composite'
+ steps:
+ - name: 'Prepare Coverage Comment'
+ id: 'prep_coverage_comment'
+ shell: 'bash'
+ env:
+ CLI_JSON_FILE: '${{ inputs.cli_json_file }}'
+ CORE_JSON_FILE: '${{ inputs.core_json_file }}'
+ CLI_FULL_TEXT_SUMMARY_FILE: '${{ inputs.cli_full_text_summary_file }}'
+ CORE_FULL_TEXT_SUMMARY_FILE: '${{ inputs.core_full_text_summary_file }}'
+ COMMENT_FILE: 'coverage-comment.md'
+ NODE_VERSION: '${{ inputs.node_version }}'
+ OS: '${{ inputs.os }}'
+ run: |-
+ # Extract percentages using jq for the main table
+ if [ -f "${CLI_JSON_FILE}" ]; then
+ cli_lines_pct="$(jq -r '.total.lines.pct' "${CLI_JSON_FILE}")"
+ cli_statements_pct="$(jq -r '.total.statements.pct' "${CLI_JSON_FILE}")"
+ cli_functions_pct="$(jq -r '.total.functions.pct' "${CLI_JSON_FILE}")"
+ cli_branches_pct="$(jq -r '.total.branches.pct' "${CLI_JSON_FILE}")"
+ else
+ cli_lines_pct="N/A"
+ cli_statements_pct="N/A"
+ cli_functions_pct="N/A"
+ cli_branches_pct="N/A"
+ echo "CLI coverage-summary.json not found at: ${CLI_JSON_FILE}" >&2 # Error to stderr
+ fi
+
+ if [ -f "${CORE_JSON_FILE}" ]; then
+ core_lines_pct="$(jq -r '.total.lines.pct' "${CORE_JSON_FILE}")"
+ core_statements_pct="$(jq -r '.total.statements.pct' "${CORE_JSON_FILE}")"
+ core_functions_pct="$(jq -r '.total.functions.pct' "${CORE_JSON_FILE}")"
+ core_branches_pct="$(jq -r '.total.branches.pct' "${CORE_JSON_FILE}")"
+ else
+ core_lines_pct="N/A"
+ core_statements_pct="N/A"
+ core_functions_pct="N/A"
+ core_branches_pct="N/A"
+ echo "Core coverage-summary.json not found at: ${CORE_JSON_FILE}" >&2 # Error to stderr
+ fi
+
+ echo "## Code Coverage Summary" > "${COMMENT_FILE}"
+ echo "" >> "${COMMENT_FILE}"
+ echo "| Package | Lines | Statements | Functions | Branches |" >> "${COMMENT_FILE}"
+ echo "|---|---|---|---|---|" >> "${COMMENT_FILE}"
+ echo "| CLI | ${cli_lines_pct}% | ${cli_statements_pct}% | ${cli_functions_pct}% | ${cli_branches_pct}% |" >> "${COMMENT_FILE}"
+ echo "| Core | ${core_lines_pct}% | ${core_statements_pct}% | ${core_functions_pct}% | ${core_branches_pct}% |" >> "${COMMENT_FILE}"
+ echo "" >> "${COMMENT_FILE}"
+
+ # CLI Package - Collapsible Section (with full text summary from file)
+ echo "" >> "${COMMENT_FILE}"
+ echo "CLI Package - Full Text Report
" >> "${COMMENT_FILE}"
+ echo "" >> "${COMMENT_FILE}"
+ echo '```text' >> "${COMMENT_FILE}"
+ if [ -f "${CLI_FULL_TEXT_SUMMARY_FILE}" ]; then
+ cat "${CLI_FULL_TEXT_SUMMARY_FILE}" >> "${COMMENT_FILE}"
+ else
+ echo "CLI full-text-summary.txt not found at: ${CLI_FULL_TEXT_SUMMARY_FILE}" >> "${COMMENT_FILE}"
+ fi
+ echo '```' >> "${COMMENT_FILE}"
+ echo " " >> "${COMMENT_FILE}"
+ echo "" >> "${COMMENT_FILE}"
+
+ # Core Package - Collapsible Section (with full text summary from file)
+ echo "" >> "${COMMENT_FILE}"
+ echo "Core Package - Full Text Report
" >> "${COMMENT_FILE}"
+ echo "" >> "${COMMENT_FILE}"
+ echo '```text' >> "${COMMENT_FILE}"
+ if [ -f "${CORE_FULL_TEXT_SUMMARY_FILE}" ]; then
+ cat "${CORE_FULL_TEXT_SUMMARY_FILE}" >> "${COMMENT_FILE}"
+ else
+ echo "Core full-text-summary.txt not found at: ${CORE_FULL_TEXT_SUMMARY_FILE}" >> "${COMMENT_FILE}"
+ fi
+ echo '```' >> "${COMMENT_FILE}"
+ echo " " >> "${COMMENT_FILE}"
+ echo "" >> "${COMMENT_FILE}"
+
+ echo "_For detailed HTML reports, please see the 'coverage-reports-${NODE_VERSION}-${OS}' artifact from the main CI run._" >> "${COMMENT_FILE}"
+
+ - name: 'Post Coverage Comment'
+ uses: 'thollander/actions-comment-pull-request@65f9e5c9a1f2cd378bd74b2e057c9736982a8e74' # ratchet:thollander/actions-comment-pull-request@v3
+ if: |-
+ ${{ always() }}
+ with:
+ file-path: 'coverage-comment.md' # Use the generated file directly
+ comment-tag: 'code-coverage-summary'
+ github-token: '${{ inputs.github_token }}'
diff --git a/.github/actions/publish-release/action.yml b/.github/actions/publish-release/action.yml
new file mode 100644
index 0000000000000000000000000000000000000000..36ac640b9770b8eadb2a91d81c150658f8057590
--- /dev/null
+++ b/.github/actions/publish-release/action.yml
@@ -0,0 +1,359 @@
+name: 'Publish Release'
+description: 'Builds, prepares, and publishes the gemini-cli packages to npm and creates a GitHub release.'
+
+inputs:
+ release-version:
+ description: 'The version to release (e.g., 0.1.11).'
+ required: true
+ npm-tag:
+ description: 'The npm tag to publish with (e.g., latest, preview, nightly).'
+ required: true
+ wombat-token-core:
+ description: 'The npm token for the cli-core package.'
+ required: true
+ wombat-token-cli:
+ description: 'The npm token for the cli package.'
+ required: true
+ wombat-token-a2a-server:
+ description: 'The npm token for the a2a package.'
+ required: true
+ github-token:
+ description: 'The GitHub token for creating the release.'
+ required: true
+ github-release-token:
+ description: 'The GitHub token used specifically for creating the GitHub release (to trigger other workflows).'
+ required: false
+ dry-run:
+ description: 'Whether to run in dry-run mode.'
+ type: 'string'
+ required: true
+ release-tag:
+ description: 'The release tag for the release (e.g., v0.1.11).'
+ required: true
+ previous-tag:
+ description: 'The previous tag to use for generating release notes.'
+ required: true
+ skip-github-release:
+ description: 'Whether to skip creating a GitHub release.'
+ type: 'boolean'
+ required: false
+ default: false
+ working-directory:
+ description: 'The working directory to run the steps in.'
+ required: false
+ default: '.'
+ force-skip-tests:
+ description: 'Skip tests and validation'
+ required: false
+ default: false
+ skip-branch-cleanup:
+ description: 'Whether to skip cleaning up the release branch.'
+ type: 'boolean'
+ required: false
+ default: false
+ gemini_api_key:
+ description: 'The API key for running integration tests.'
+ required: true
+ npm-registry-publish-url:
+ description: 'npm registry publish url'
+ required: true
+ npm-registry-url:
+ description: 'npm registry url'
+ required: true
+ npm-registry-scope:
+ description: 'npm registry scope'
+ required: true
+ cli-package-name:
+ description: 'The name of the cli package.'
+ required: true
+ core-package-name:
+ description: 'The name of the core package.'
+ required: true
+ a2a-package-name:
+ description: 'The name of the a2a package.'
+ required: true
+runs:
+ using: 'composite'
+ steps:
+ - name: '👤 Configure Git User'
+ working-directory: '${{ inputs.working-directory }}'
+ shell: 'bash'
+ run: |
+ git config user.name "gemini-cli-robot"
+ git config user.email "gemini-cli-robot@google.com"
+
+ - name: '🌿 Create and switch to a release branch'
+ working-directory: '${{ inputs.working-directory }}'
+ id: 'release_branch'
+ shell: 'bash'
+ run: |
+ 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 }}'
+
+ - name: '💾 Commit and Conditionally Push package versions'
+ working-directory: '${{ inputs.working-directory }}'
+ shell: 'bash'
+ env:
+ BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
+ DRY_RUN: '${{ inputs.dry-run }}'
+ RELEASE_TAG: '${{ inputs.release-tag }}'
+ GIT_PUSH_TOKEN: '${{ inputs.github-release-token || inputs.github-token }}'
+ run: |-
+ set -e
+ git add package.json package-lock.json packages/*/package.json
+ git commit -m "chore(release): ${RELEASE_TAG}"
+ if [[ "${DRY_RUN}" == "false" ]]; then
+ echo "Pushing release branch to remote..."
+ git push "https://x-access-token:${GIT_PUSH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:${BRANCH_NAME}" --follow-tags
+ else
+ echo "Dry run enabled. Skipping push."
+ fi
+
+ - name: '🛠️ Build and Prepare Packages'
+ working-directory: '${{ inputs.working-directory }}'
+ shell: 'bash'
+ run: |
+ npm run build:packages
+ npm run prepare:package
+
+ - name: '🎁 Bundle'
+ working-directory: '${{ inputs.working-directory }}'
+ shell: 'bash'
+ run: |
+ npm run bundle
+
+ # TODO: Refactor this github specific publishing script to be generalized based upon inputs.
+ - name: '📦 Prepare for GitHub release'
+ if: "inputs.npm-registry-url == 'https://npm.pkg.github.com/'"
+ working-directory: '${{ inputs.working-directory }}'
+ shell: 'bash'
+ run: |
+ node ${{ github.workspace }}/scripts/prepare-github-release.js
+
+ - name: 'Configure npm for publishing to npm'
+ uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020'
+ with:
+ node-version-file: '${{ inputs.working-directory }}/.nvmrc'
+ registry-url: '${{inputs.npm-registry-publish-url}}'
+ scope: '${{inputs.npm-registry-scope}}'
+
+ - name: 'Get core Token'
+ uses: './.github/actions/npm-auth-token'
+ id: 'core-token'
+ with:
+ package-name: '${{ inputs.core-package-name }}'
+ github-token: '${{ inputs.github-token }}'
+ wombat-token-core: '${{ inputs.wombat-token-core }}'
+ wombat-token-cli: '${{ inputs.wombat-token-cli }}'
+ wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}'
+
+ - name: '📦 Publish CORE to NPM'
+ 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 \
+ --ignore-scripts \
+ --dry-run="${INPUTS_DRY_RUN}" \
+ --workspace="${INPUTS_CORE_PACKAGE_NAME}" \
+ --tag staging-tmp
+ if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
+ npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} staging-tmp || echo "Warning: Failed to remove staging-tmp tag (this is normal on registries that forbid tag deletion, like Wombat)"
+ fi
+
+ - 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}" \
+ --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: '📦 Prepare bundled CLI for npm release'
+ if: "inputs.npm-registry-url != 'https://npm.pkg.github.com/'"
+ working-directory: '${{ inputs.working-directory }}'
+ shell: 'bash'
+ run: |
+ node ${{ github.workspace }}/scripts/prepare-npm-release.js
+
+ - name: '📦 Pack CLI for verification'
+ if: "inputs.dry-run != 'true' && inputs.force-skip-tests != 'true'"
+ working-directory: '${{ inputs.working-directory }}'
+ shell: 'bash'
+ run: |
+ npm pack --workspace="${INPUTS_CLI_PACKAGE_NAME}"
+ # We restore the package.json so that `npm ci` in verify-release doesn't fail due to deleted dependencies
+ git checkout packages/cli/package.json
+ env:
+ INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}'
+
+ - name: '🔬 Verify NPM release by version'
+ uses: './.github/actions/verify-release'
+ if: "${{ inputs.dry-run != 'true' && inputs.force-skip-tests != 'true' }}"
+ with:
+ npm-package: './google-gemini-cli-${{ inputs.release-version }}.tgz'
+ expected-version: '${{ inputs.release-version }}'
+ working-directory: '${{ inputs.working-directory }}'
+ gemini_api_key: '${{ inputs.gemini_api_key }}'
+ github-token: '${{ inputs.github-token }}'
+ npm-registry-url: '${{ inputs.npm-registry-url }}'
+ npm-registry-scope: '${{ inputs.npm-registry-scope }}'
+
+ - name: 'Get CLI Token'
+ uses: './.github/actions/npm-auth-token'
+ id: 'cli-token'
+ with:
+ package-name: '${{ inputs.cli-package-name }}'
+ github-token: '${{ inputs.github-token }}'
+ wombat-token-core: '${{ inputs.wombat-token-core }}'
+ wombat-token-cli: '${{ inputs.wombat-token-cli }}'
+ wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}'
+
+ - name: '📦 Publish CLI'
+ 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 }}'
+ INPUTS_RELEASE_VERSION: '${{ inputs.release-version }}'
+ shell: 'bash'
+ run: |
+ if [ -f "google-gemini-cli-${INPUTS_RELEASE_VERSION}.tgz" ]; then
+ PUBLISH_TARGET="google-gemini-cli-${INPUTS_RELEASE_VERSION}.tgz"
+ else
+ PUBLISH_TARGET="--workspace=${INPUTS_CLI_PACKAGE_NAME}"
+ fi
+
+ npm publish \
+ --ignore-scripts \
+ --dry-run="${INPUTS_DRY_RUN}" \
+ ${PUBLISH_TARGET} \
+ --tag staging-tmp
+ if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
+ npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} staging-tmp || echo "Warning: Failed to remove staging-tmp tag (this is normal on registries that forbid tag deletion, like Wombat)"
+ fi
+
+ - name: 'Get a2a-server Token'
+ uses: './.github/actions/npm-auth-token'
+ id: 'a2a-token'
+ with:
+ package-name: '${{ inputs.a2a-package-name }}'
+ github-token: '${{ inputs.github-token }}'
+ wombat-token-core: '${{ inputs.wombat-token-core }}'
+ wombat-token-cli: '${{ inputs.wombat-token-cli }}'
+ wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}'
+
+ - name: '📦 Publish a2a'
+ 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 \
+ --ignore-scripts \
+ --dry-run="${INPUTS_DRY_RUN}" \
+ --workspace="${INPUTS_A2A_PACKAGE_NAME}" \
+ --tag staging-tmp
+ if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
+ npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} staging-tmp || echo "Warning: Failed to remove staging-tmp tag (this is normal on registries that forbid tag deletion, like Wombat)"
+ fi
+
+ - name: '🏷️ Tag release'
+ uses: './.github/actions/tag-npm-release'
+ with:
+ channel: '${{ inputs.npm-tag }}'
+ version: '${{ inputs.release-version }}'
+ dry-run: '${{ inputs.dry-run }}'
+ github-token: '${{ inputs.github-token }}'
+ wombat-token-core: '${{ inputs.wombat-token-core }}'
+ wombat-token-cli: '${{ inputs.wombat-token-cli }}'
+ wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}'
+ cli-package-name: '${{ inputs.cli-package-name }}'
+ core-package-name: '${{ inputs.core-package-name }}'
+ a2a-package-name: '${{ inputs.a2a-package-name }}'
+ working-directory: '${{ inputs.working-directory }}'
+
+ - name: '🎉 Create GitHub Release'
+ working-directory: '${{ inputs.working-directory }}'
+ 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-release-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: |
+ rm -f gemini-cli-bundle.zip
+ (cd bundle && chmod +x gemini.js && zip -r ../gemini-cli-bundle.zip .)
+
+ echo "Testing the generated bundle archive..."
+ rm -rf test-bundle
+ mkdir -p test-bundle
+ unzip -q gemini-cli-bundle.zip -d test-bundle
+
+ # Verify it runs and outputs a version
+ BUNDLE_VERSION=$(node test-bundle/gemini.js --version | xargs)
+ echo "Bundle version output: ${BUNDLE_VERSION}"
+ if [[ -z "${BUNDLE_VERSION}" ]]; then
+ echo "Error: Bundle failed to execute or return version."
+ exit 1
+ fi
+ rm -rf test-bundle
+
+ RELEASE_ASSETS=("gemini-cli-bundle.zip")
+
+ # Check for and prepare macOS binaries if they exist
+ for arch in arm64 x64; do
+ BINARY_PATH="dist/darwin-${arch}/gemini"
+ if [[ -f "$BINARY_PATH" ]]; then
+ chmod +x "$BINARY_PATH"
+ ZIP_NAME="gemini-darwin-${arch}-unsigned.zip"
+ zip -j "$ZIP_NAME" "$BINARY_PATH"
+ RELEASE_ASSETS+=("$ZIP_NAME")
+ fi
+ done
+
+ gh release create "${INPUTS_RELEASE_TAG}" \
+ "${RELEASE_ASSETS[@]}" \
+ --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' || '' }}
+
+ - name: '🧹 Clean up release branch'
+ working-directory: '${{ inputs.working-directory }}'
+ if: "${{ inputs.dry-run != 'true' && inputs.skip-branch-cleanup != 'true' }}"
+ continue-on-error: true
+ shell: 'bash'
+ run: |
+ echo "Cleaning up release branch ${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}..."
+ git push "https://x-access-token:${GIT_PUSH_TOKEN}@github.com/${{ github.repository }}.git" --delete "${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}"
+
+ env:
+ GIT_PUSH_TOKEN: '${{ inputs.github-release-token || inputs.github-token }}'
+ STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
diff --git a/.github/actions/push-docker/action.yml b/.github/actions/push-docker/action.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7644b89412d2a1160216ae085c31e33eb26f9696
--- /dev/null
+++ b/.github/actions/push-docker/action.yml
@@ -0,0 +1,75 @@
+name: 'Push to docker'
+description: 'Builds packages and pushes a docker image to GHCR'
+
+inputs:
+ github-actor:
+ description: 'Github actor'
+ required: true
+ github-secret:
+ description: 'Github secret'
+ required: true
+ ref-name:
+ description: 'Github ref name'
+ required: true
+ github-sha:
+ description: 'Github Commit SHA Hash'
+ required: true
+
+runs:
+ using: 'composite'
+ steps:
+ - name: 'Checkout'
+ uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4
+ with:
+ ref: '${{ inputs.github-sha }}'
+ fetch-depth: 0
+ - name: 'Install Dependencies'
+ shell: 'bash'
+ run: 'npm install'
+ - name: 'Set up Docker Buildx'
+ uses: 'docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435' # ratchet:docker/setup-buildx-action@v3
+ - name: 'build'
+ shell: 'bash'
+ run: 'npm run build'
+ - name: 'pack @google/gemini-cli'
+ shell: 'bash'
+ run: 'npm pack -w @google/gemini-cli --pack-destination ./packages/cli/dist'
+ - name: 'pack @google/gemini-cli-core'
+ shell: 'bash'
+ run: 'npm pack -w @google/gemini-cli-core --pack-destination ./packages/core/dist'
+ - name: 'Log in to GitHub Container Registry'
+ uses: 'docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1' # ratchet:docker/login-action@v3
+ with:
+ registry: 'ghcr.io'
+ username: '${{ inputs.github-actor }}'
+ password: '${{ inputs.github-secret }}'
+ - name: 'Get branch name'
+ id: 'branch_name'
+ shell: 'bash'
+ run: |
+ 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:
+ context: '.'
+ file: './Dockerfile'
+ push: true
+ provenance: false # avoid pushing 3 images to Aritfact Registry
+ tags: |
+ ghcr.io/${{ github.repository }}/cli:${{ steps.branch_name.outputs.name }}
+ ghcr.io/${{ github.repository }}/cli:${{ inputs.github-sha }}
+ - name: 'Create issue on failure'
+ if: |-
+ ${{ failure() }}
+ shell: 'bash'
+ env:
+ GITHUB_TOKEN: '${{ inputs.github-secret }}'
+ DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
+ run: |-
+ gh issue create \
+ --title "Docker build failed" \
+ --body "The docker build failed. See the full run for details: ${DETAILS_URL}" \
+ --label "release-failure"
diff --git a/.github/actions/push-sandbox/action.yml b/.github/actions/push-sandbox/action.yml
new file mode 100644
index 0000000000000000000000000000000000000000..38fdfd3f12661e0b82a7db991dd9eeea7c9bdbb4
--- /dev/null
+++ b/.github/actions/push-sandbox/action.yml
@@ -0,0 +1,114 @@
+name: 'Build and push sandbox docker'
+description: 'Pushes sandbox docker image to container registry'
+
+inputs:
+ github-actor:
+ description: 'Github actor'
+ required: true
+ github-secret:
+ description: 'Github secret'
+ required: true
+ dockerhub-username:
+ description: 'Dockerhub username'
+ required: true
+ dockerhub-token:
+ description: 'Dockerhub PAT w/ R+W'
+ required: true
+ github-sha:
+ description: 'Github Commit SHA Hash'
+ required: true
+ github-ref-name:
+ description: 'Github ref name'
+ required: true
+ dry-run:
+ description: 'Whether this is a dry run.'
+ required: true
+ type: 'boolean'
+
+runs:
+ using: 'composite'
+ steps:
+ - name: 'Checkout'
+ uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
+ with:
+ ref: '${{ inputs.github-sha }}'
+ fetch-depth: 0
+ - name: 'Install Dependencies'
+ shell: 'bash'
+ run: 'npm install'
+ - name: 'npm build'
+ shell: 'bash'
+ run: 'npm run build'
+ - name: 'Set up QEMU'
+ uses: 'docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130' # ratchet:docker/setup-qemu-action@v3
+ - name: 'Set up Docker Buildx'
+ uses: 'docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f' # ratchet:docker/setup-buildx-action@v3
+ - name: 'Log in to GitHub Container Registry'
+ uses: 'docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9' # ratchet:docker/login-action@v3
+ with:
+ registry: 'docker.io'
+ username: '${{ inputs.dockerhub-username }}'
+ password: '${{ inputs.dockerhub-token }}'
+ - name: 'determine image tag'
+ id: 'image_tag'
+ shell: 'bash'
+ run: |-
+ 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}"
+ else
+ echo "Development release detected. Using commit SHA as tag."
+ 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 }}'
+ # We build amd64 just so we can verify it.
+ # We build and push both amd64 and arm64 in the publish step.
+ - name: 'build'
+ id: 'docker_build'
+ shell: 'bash'
+ env:
+ GEMINI_SANDBOX_IMAGE_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}'
+ GEMINI_SANDBOX: 'docker'
+ BUILD_SANDBOX_FLAGS: '--platform linux/amd64 --load'
+ 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}" \
+ --output-file final_image_uri.txt
+ echo "uri=$(cat final_image_uri.txt)" >> $GITHUB_OUTPUT
+ - name: 'verify'
+ shell: 'bash'
+ run: |-
+ docker run --rm --entrypoint sh "${{ steps.docker_build.outputs.uri }}" -lc '
+ set -e
+ node -e "const fs=require(\"node:fs\"); JSON.parse(fs.readFileSync(\"/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json\",\"utf8\")); JSON.parse(fs.readFileSync(\"/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json\",\"utf8\"));"
+ /usr/local/share/npm-global/bin/gemini --version >/dev/null
+ '
+ - name: 'publish'
+ shell: 'bash'
+ if: "${{ inputs.dry-run != 'true' }}"
+ env:
+ GEMINI_SANDBOX_IMAGE_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}'
+ GEMINI_SANDBOX: 'docker'
+ BUILD_SANDBOX_FLAGS: '--platform linux/amd64,linux/arm64 --push'
+ 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}"
+ - name: 'Create issue on failure'
+ if: |-
+ ${{ failure() }}
+ shell: 'bash'
+ env:
+ GITHUB_TOKEN: '${{ inputs.github-secret }}'
+ DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
+ run: |-
+ gh issue create \
+ --title "Docker build failed" \
+ --body "The docker build failed. See the full run for details: ${DETAILS_URL}" \
+ --label "release-failure"
diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6dd818bb569135092e992b82ee5611b251d18ee8
--- /dev/null
+++ b/.github/actions/run-tests/action.yml
@@ -0,0 +1,41 @@
+name: 'Run Tests'
+description: 'Runs the preflight checks and integration tests.'
+
+inputs:
+ gemini_api_key:
+ description: 'The API key for running integration tests.'
+ required: true
+ working-directory:
+ description: 'The working directory to run the tests in.'
+ required: false
+ default: '.'
+
+runs:
+ using: 'composite'
+ steps:
+ - name: 'Install system dependencies'
+ if: "runner.os == 'Linux'"
+ run: |
+ sudo apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq bubblewrap
+ # Ubuntu 24.04+ requires this to allow bwrap to function in CI
+ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
+ shell: 'bash'
+ - name: 'Run Tests'
+ env:
+ GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
+ GEMINI_CLI_TRUST_WORKSPACE: true
+ working-directory: '${{ inputs.working-directory }}'
+ run: |-
+ echo "::group::Build"
+ npm run build
+ echo "::endgroup::"
+ echo "::group::Unit Tests"
+ npm run test:ci
+ echo "::endgroup::"
+ echo "::group::Integration Tests (no sandbox)"
+ npm run test:integration:sandbox:none
+ echo "::endgroup::"
+ echo "::group::Integration Tests (docker sandbox)"
+ npm run test:integration:sandbox:docker
+ echo "::endgroup::"
+ shell: 'bash'
diff --git a/.github/actions/setup-npmrc/action.yml b/.github/actions/setup-npmrc/action.yml
new file mode 100644
index 0000000000000000000000000000000000000000..137451740f119390fc0fd75547c4ebdb1774e108
--- /dev/null
+++ b/.github/actions/setup-npmrc/action.yml
@@ -0,0 +1,24 @@
+name: 'Setup NPMRC'
+description: 'Sets up NPMRC with all the correct repos for readonly access.'
+
+inputs:
+ github-token:
+ description: 'the github token'
+ required: true
+
+outputs:
+ auth-token:
+ description: 'The generated NPM auth token'
+ value: '${{ steps.npm_auth_token.outputs.auth-token }}'
+
+runs:
+ using: 'composite'
+ steps:
+ - name: 'Configure .npmrc'
+ shell: 'bash'
+ run: |-
+ echo ""@google-gemini:registry=https://npm.pkg.github.com"" > ~/.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 }}'
diff --git a/.github/actions/tag-npm-release/action.yml b/.github/actions/tag-npm-release/action.yml
new file mode 100644
index 0000000000000000000000000000000000000000..479b5be6237855b84935ab90a24aa7be5c756fa4
--- /dev/null
+++ b/.github/actions/tag-npm-release/action.yml
@@ -0,0 +1,139 @@
+name: 'Tag an NPM release'
+description: 'Tags a specific npm version to a specific channel.'
+
+inputs:
+ channel:
+ description: 'NPM Channel tag'
+ required: true
+ version:
+ description: 'version'
+ required: true
+ dry-run:
+ description: 'Whether to run in dry-run mode.'
+ required: true
+ github-token:
+ description: 'The GitHub token for creating the release.'
+ required: true
+ wombat-token-core:
+ description: 'The npm token for the wombat @google/gemini-cli-core'
+ required: true
+ wombat-token-cli:
+ description: 'The npm token for wombat @google/gemini-cli'
+ required: true
+ wombat-token-a2a-server:
+ description: 'The npm token for the @google/gemini-cli-a2a-server package.'
+ required: true
+ cli-package-name:
+ description: 'The name of the cli package.'
+ required: true
+ core-package-name:
+ description: 'The name of the core package.'
+ required: true
+ a2a-package-name:
+ description: 'The name of the a2a package.'
+ required: true
+ working-directory:
+ description: 'The working directory to run the commands in.'
+ required: false
+ default: '.'
+
+runs:
+ using: 'composite'
+ steps:
+ - name: 'Setup Node.js'
+ uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020'
+ with:
+ node-version-file: '${{ inputs.working-directory }}/.nvmrc'
+
+ - name: 'configure .npmrc'
+ uses: './.github/actions/setup-npmrc'
+ with:
+ github-token: '${{ inputs.github-token }}'
+
+ - name: 'Get core Token'
+ uses: './.github/actions/npm-auth-token'
+ id: 'core-token'
+ with:
+ package-name: '${{ inputs.core-package-name }}'
+ github-token: '${{ inputs.github-token }}'
+ wombat-token-core: '${{ inputs.wombat-token-core }}'
+ wombat-token-cli: '${{ inputs.wombat-token-cli }}'
+ wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}'
+
+ - name: 'Change tag for CORE'
+ if: |-
+ ${{ 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}
+
+ - name: 'Get cli Token'
+ uses: './.github/actions/npm-auth-token'
+ id: 'cli-token'
+ with:
+ package-name: '${{ inputs.cli-package-name }}'
+ github-token: '${{ inputs.github-token }}'
+ wombat-token-core: '${{ inputs.wombat-token-core }}'
+ wombat-token-cli: '${{ inputs.wombat-token-cli }}'
+ wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}'
+
+ - name: 'Change tag for CLI'
+ if: |-
+ ${{ 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}
+
+ - name: 'Get a2a Token'
+ uses: './.github/actions/npm-auth-token'
+ id: 'a2a-token'
+ with:
+ package-name: '${{ inputs.a2a-package-name }}'
+ github-token: '${{ inputs.github-token }}'
+ wombat-token-core: '${{ inputs.wombat-token-core }}'
+ wombat-token-cli: '${{ inputs.wombat-token-cli }}'
+ wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}'
+
+ - name: 'Change tag for a2a'
+ if: |-
+ ${{ 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}
+
+ - name: 'Log dry run'
+ if: |-
+ ${{ inputs.dry-run == 'true' }}
+ 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 }}'
diff --git a/.github/actions/verify-release/action.yml b/.github/actions/verify-release/action.yml
new file mode 100644
index 0000000000000000000000000000000000000000..92e7690ae5e65d039a83e503897e3db4143b4cbb
--- /dev/null
+++ b/.github/actions/verify-release/action.yml
@@ -0,0 +1,103 @@
+name: 'Verify an NPM release'
+description: 'Fetches a package from NPM and does some basic smoke tests'
+
+inputs:
+ npm-package:
+ description: 'NPM Package'
+ required: true
+ default: '@google/gemini-cli@latest'
+ npm-registry-url:
+ description: 'NPM Registry URL'
+ required: true
+ npm-registry-scope:
+ description: 'NPM Registry Scope'
+ required: true
+ expected-version:
+ description: 'Expected version'
+ required: true
+ gemini_api_key:
+ description: 'The API key for running integration tests.'
+ required: true
+ github-token:
+ description: 'The GitHub token for running integration tests.'
+ required: true
+ working-directory:
+ description: 'The working directory to run the tests in.'
+ required: false
+ default: '.'
+
+runs:
+ using: 'composite'
+ steps:
+ - name: 'setup node'
+ uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: 'configure .npmrc'
+ uses: './.github/actions/setup-npmrc'
+ with:
+ github-token: '${{ inputs.github-token }}'
+
+ - name: 'Clear npm cache'
+ shell: 'bash'
+ run: 'npm cache clean --force'
+
+ - name: 'Install from NPM'
+ uses: 'nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08' # ratchet:nick-fields/retry@v3
+ with:
+ timeout_seconds: 900
+ retry_wait_seconds: 30
+ max_attempts: 10
+ command: |-
+ cd ${{ inputs.working-directory }}
+ npm install --prefer-online --no-cache -g "${{ inputs.npm-package }}"
+
+ - name: 'Smoke test - NPM Install'
+ shell: 'bash'
+ 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}"
+ exit 1
+ fi
+ env:
+ INPUTS_EXPECTED_VERSION: '${{ inputs.expected-version }}'
+ INPUTS_NPM_PACKAGE: '${{ inputs.npm-package }}'
+
+ - name: 'Clear npm cache'
+ shell: 'bash'
+ run: 'npm cache clean --force'
+
+ - name: 'Smoke test - NPX Run'
+ shell: 'bash'
+ working-directory: '${{ inputs.working-directory }}'
+ run: |-
+ gemini_version=$(npx --yes --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'
+ working-directory: '${{ inputs.working-directory }}'
+ run: 'npm ci --ignore-scripts'
+
+ - name: '🔬 Run integration tests against NPM release'
+ working-directory: '${{ inputs.working-directory }}'
+ env:
+ GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
+ GEMINI_CLI_TRUST_WORKSPACE: true
+ INTEGRATION_TEST_USE_INSTALLED_GEMINI: 'true'
+ # We must diable CI mode here because it interferes with interactive tests.
+ # See https://github.com/google-gemini/gemini-cli/issues/10517
+ CI: 'false'
+ shell: 'bash'
+ run: |
+ export INTEGRATION_TEST_GEMINI_BINARY_PATH=$(which gemini)
+ npm run test:integration:sandbox:none
diff --git a/packages/a2a-server/GEMINI.md b/packages/a2a-server/GEMINI.md
new file mode 100644
index 0000000000000000000000000000000000000000..34e487e3bb72008e61ad7e000fd34fece3945057
--- /dev/null
+++ b/packages/a2a-server/GEMINI.md
@@ -0,0 +1,22 @@
+# Gemini CLI A2A Server (`@google/gemini-cli-a2a-server`)
+
+Experimental Agent-to-Agent (A2A) server that exposes Gemini CLI capabilities
+over HTTP for inter-agent communication.
+
+## Architecture
+
+- `src/agent/`: Agent session management for A2A interactions.
+- `src/commands/`: CLI command definitions for the A2A server binary.
+- `src/config/`: Server configuration.
+- `src/http/`: HTTP server and route handlers.
+- `src/persistence/`: Session and state persistence.
+- `src/utils/`: Shared utility functions.
+- `src/types.ts`: Shared type definitions.
+
+## Running
+
+- Binary entry point: `gemini-cli-a2a-server`
+
+## Testing
+
+- Run tests: `npm test -w @google/gemini-cli-a2a-server`
diff --git a/packages/a2a-server/README.md b/packages/a2a-server/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..bd6a2fac45aa6370e2b476c4637eb64289d6c832
--- /dev/null
+++ b/packages/a2a-server/README.md
@@ -0,0 +1,5 @@
+# Gemini CLI A2A Server
+
+## All code in this package is experimental and under active development
+
+This package contains the A2A server implementation for the Gemini CLI.
diff --git a/packages/a2a-server/development-extension-rfc.md b/packages/a2a-server/development-extension-rfc.md
new file mode 100644
index 0000000000000000000000000000000000000000..e749a5ff09ad1394db608d35dfcb86d7a9f60925
--- /dev/null
+++ b/packages/a2a-server/development-extension-rfc.md
@@ -0,0 +1,509 @@
+# RFC: Gemini CLI A2A Development-Tool Extension
+
+## 1. Introduction
+
+### 1.1 Overview
+
+To standardize client integrations with the Gemini CLI agent, this document
+proposes the `development-tool` extension for the A2A protocol.
+
+Rather than creating a new protocol, this specification builds upon the existing
+A2A protocol. As an open-source standard recently adopted by the Linux
+Foundation, A2A provides a robust foundation for core concepts like tasks,
+messages, and streaming events. This extension-based approach allows us to
+leverage A2A's proven architecture while defining the specific capabilities
+required for rich, interactive workflows with the Gemini CLI agent.
+
+### 1.2 Motivation
+
+Recent work integrating Gemini CLI with clients like Zed and Gemini Code
+Assist’s agent mode has highlighted the need for a robust, standard
+communication protocol. Standardizing on A2A provides several key advantages:
+
+- **Solid Foundation**: Provides a robust, open standard that ensures a stable,
+ predictable, and consistent integration experience across different IDEs and
+ client surfaces.
+- **Extensibility**: Creates a flexible foundation to support new tools and
+ workflows as they emerge.
+- **Ecosystem Alignment**: Aligns Gemini CLI with a growing industry standard,
+ fostering broader interoperability.
+
+## 2. Communication Flow
+
+The interaction follows A2A’s task-based, streaming pattern. The client sends a
+`message/stream` request and the agent responds with a `contextId` / `taskId`
+and a stream of events. `TaskStatusUpdateEvent` events are used to convey the
+overall state of the task. The task is complete when the agent sends a final
+`TaskStatusUpdateEvent` with `final: true` and a terminal status like
+`completed` or `failed`.
+
+### 2.1 Asynchronous Responses and Notifications
+
+Clients that may disconnect from the agent should supply a
+`PushNotificationConfig` to the agent with the initial `message/stream` method
+or subsequently with the `tasks/pushNotificationConfig/set` method so that the
+agent can call back when updates are ready.
+
+## 3. The `development-tool` extension
+
+### 3.1 Overview
+
+The `development-tool` extension establishes a communication contract for
+workflows between a client and the Gemini CLI agent. It consists of a
+specialized set of schemas, embedded within core A2A data structures, that
+enable the agent to stream real-time updates on its state and thought process.
+These schemas also provide the mechanism for the agent to request user
+permission before executing tools.
+
+**Sample Agent Card**
+
+```json
+{
+ "name": "Gemini CLI Agent",
+ "description": "An agent that generates code based on natural language instructions.",
+ "capabilities": {
+ "streaming": true,
+ "extensions": [
+ {
+ "uri": "https://github.com/google-gemini/gemini-cli/blob/main/docs/a2a/developer-profile/v0/spec.md",
+ "description": "An extension for interactive development tasks, enabling features like code generation, tool usage, and real-time status updates.",
+ "required": true
+ }
+ ]
+ }
+}
+```
+
+**Versioning**
+
+The agent card `uri` field contains an embedded semantic version. The client
+must extract this version to determine compatibility with the agent extension
+using the compatibility logic defined in Semantic Versioning 2.0.0 spec.
+
+### 3.2 Schema Definitions
+
+This section defines the schemas for the `development-tool` A2A extension,
+organized by their function within the communication flow. Note that all custom
+objects included in the `metadata` field (e.g. `Message.metadata`) must be keyed
+by the unique URI that points to that extension’s spec to prevent naming
+collisions with other extensions.
+
+**Initialization & Configuration**
+
+The first message in a session must contain an `AgentSettings` object in its
+metadata. This object provides the agent with the necessary configuration
+information for proper initialization. Additional configuration settings (ex.
+MCP servers, allowed tools, etc.) can be added to this message.
+
+**Schema**
+
+```proto
+syntax = "proto3";
+
+// Configuration settings for the Gemini CLI agent.
+message AgentSettings {
+ // The absolute path to the workspace directory where the agent will execute.
+ string workspace_path = 1;
+}
+```
+
+**Agent-to-Client Messages**
+
+All real-time updates from the agent (including its thoughts, tool calls, and
+simple text replies) are streamed to the client as `TaskStatusUpdateEvents`.
+
+Each Event contains a `Message` object, which holds the content in one of two
+formats:
+
+- **TextPart**: Used for standard text messages. This part requires no custom
+ schema.
+- **DataPart**: Used for complex, structured objects. Tool Calls and Thoughts
+ are sent this way, each using their respective schemas defined below.
+
+**Tool Calls**
+
+The `ToolCall` schema is designed to provide a structured representation of a
+tool’s execution lifecycle. This protocol defines a clear state machine and
+provides detailed schemas for common development tasks (file edits, shell
+commands, MCP Tool), ensuring clients can build reliable UIs without being tied
+to a specific agent implementation.
+
+The core principle is that the agent sends a `ToolCall` object on every update.
+This makes client-side logic stateless and simple.
+
+**Tool Call Lifecycle**
+
+1. **Creation**: The agent sends a `ToolCall` object with `status: PENDING`. If
+ user permission is required, the `confirmation_request` field will be
+ populated.
+2. **Confirmation**: If the client needs to confirm the message, the client
+ will send a `ToolCallConfirmation`. If the client responds with a
+ cancellation, execution will be skipped.
+3. **Execution**: Once approved (or if no approval is required), the agent
+ sends an update with `status: EXECUTING`. It can stream real-time progress
+ by updating the `live_content` field.
+4. **Completion**: The agent sends a final update with the status set to
+ `SUCCEEDED`, `FAILED`, or `CANCELLED` and populates the appropriate result
+ field.
+
+**Schema**
+
+```proto
+syntax = "proto3";
+
+import "google/protobuf/struct.proto";
+
+// ToolCall is the central message representing a tool's execution lifecycle.
+// The entire object is sent from the agent to client on every update.
+message ToolCall {
+ // A unique identifier, assigned by the agent
+ string tool_call_id = 1;
+
+ // The current state of the tool call in its lifecycle
+ ToolCallStatus status = 2;
+
+ // Name of the tool being called (e.g. 'Edit', 'ShellTool')
+ string tool_name = 3;
+
+ // An optional description of the tool call's purpose to show the user
+ optional string description = 4;
+
+ // The structured input params provided by the LLM for tool invocation.
+ google.protobuf.Struct input_parameters = 5;
+
+ // String containing the real-time output from the tool as it executes (primarily designed for shell output).
+ // During streaming the entire string is replaced on each update
+ optional string live_content = 6;
+
+ // The final result of the tool (used to replace live_content when applicable)
+ oneof result {
+ // The output on tool success
+ ToolOutput output = 7;
+ // The error details if the tool failed
+ ErrorDetails error = 8;
+ }
+
+ // If the tool requires user confirmation, this field will be populated while status is PENDING
+ optional ConfirmationRequest confirmation_request = 9;
+}
+
+// Possible execution status of a ToolCall
+enum ToolCallStatus {
+ STATUS_UNSPECIFIED = 0;
+ PENDING = 1;
+ EXECUTING = 2;
+ SUCCEEDED = 3;
+ FAILED = 4;
+ CANCELLED = 5;
+}
+
+// ToolOutput represents the final, successful, output of a tool
+message ToolOutput {
+ oneof result {
+ string text = 1;
+ // For ToolCalls which resulted in a file modification
+ FileDiff diff = 2;
+ // A generic fallback for any other structured JSON data
+ google.protobuf.Struct structured_data = 3;
+ }
+}
+
+// A structured representation of an error
+message ErrorDetails {
+ // User facing error message
+ string message = 1;
+ // Optional agent-specific error type or category (e.g. read_content_failure, grep_execution_error, mcp_tool_error)
+ optional string type = 2;
+ // Optional status code
+ optional int32 status_code = 3;
+}
+
+// ConfirmationRequest is sent from the agent to client to request user permission for a ToolCall
+message ConfirmationRequest {
+ // A list of choices for the user to select from
+ repeated ConfirmationOption options = 1;
+ // Specific details of the action requiring user confirmation
+ oneof details {
+ ExecuteDetails execute_details = 2;
+ FileDiff file_edit_details = 3;
+ McpDetails mcp_details = 4;
+ GenericDetails generic_details = 5;
+ }
+}
+
+// A single choice presented to the user during a confirmation request
+message ConfirmationOption {
+ // Unique ID for the choice (e.g. proceed_once, cancel)
+ string id = 1;
+ // Human-readable choice (e.g. Allow Once, Reject).
+ string name = 2;
+ // An optional longer description for a tooltip
+ optional string description = 3;
+}
+
+// Details for a request to execute a shell command
+message ExecuteDetails {
+ // The shell command to be executed
+ string command = 1;
+ // An optional directory in which the command will be run
+ optional string working_directory = 2;
+}
+
+
+message FileDiff {
+ string file_name = 1;
+ // The absolute path to the file to modify
+ string file_path = 2;
+ // The original content, if the file exists
+ optional string old_content = 3;
+ string new_content = 4;
+ // Pre-formatted diff string for display
+ optional string formatted_diff = 5;
+}
+
+// Details for an MCP (Model Context Protocol) tool confirmation
+message McpDetails {
+ // The name of the MCP server that provides the tool
+ string server_name = 1;
+ // THe name of the tool being called from the MCP Server
+ string tool_name = 2;
+}
+
+// Generic catch-all for ToolCall requests that don't fit other types
+message GenericDetails {
+ // Description of the action requiring confirmation
+ string description = 1;
+}
+```
+
+**Agent Thoughts**
+
+**Schema**
+
+```proto
+syntax = "proto3";
+
+// Represents a thought with a subject and a detailed description.
+message AgentThought {
+ // A concise subject line or title for the thought.
+ string subject = 1;
+
+ // The description or elaboration of the thought itself.
+ string description = 2;
+}
+```
+
+**Event Metadata**
+
+The `metadata` object in `TaskStatusUpdateEvent` is used by the A2A client to
+deserialize the `TaskStatusUpdateEvents` into their appropriate objects.
+
+**Schema**
+
+```proto
+syntax = "proto3";
+
+// A DevelopmentToolEvent event.
+message DevelopmentToolEvent {
+ // Enum representing the specific type of development tool event.
+ enum DevelopmentToolEventKind {
+ // The default, unspecified value.
+ DEVELOPMENT_TOOL_EVENT_KIND_UNSPECIFIED = 0;
+ TOOL_CALL_CONFIRMATION = 1;
+ TOOL_CALL_UPDATE = 2;
+ TEXT_CONTENT = 3;
+ STATE_CHANGE = 4;
+ THOUGHT = 5;
+ }
+
+ // The specific kind of event that occurred.
+ DevelopmentToolEventKind kind = 1;
+
+ // The model used for this event.
+ string model = 2;
+
+ // The tier of the user (optional).
+ string user_tier = 3;
+
+ // An unexpected error occurred in the agent execution (optional).
+ string error = 4;
+}
+```
+
+**Client-to-Agent Messages**
+
+When the agent sends a `TaskStatusUpdateEvent` with `status.state` set to
+`input-required` and its message contains a `ConfirmationRequest`, the client
+must respond by sending a new `message/stream` request.
+
+This new request must include the `contextId` and the `taskId` from the ongoing
+task and contain a `ToolCallConfirmation` object. This object conveys the user's
+decision regarding the tool call that was awaiting approval.
+
+**Schema**
+
+```proto
+syntax = "proto3";
+
+// The client's response to a ConfirmationRequest.
+message ToolCallConfirmation {
+ // A unique identifier, assigned by the agent
+ string tool_call_id = 1;
+ // The 'id' of the ConfirmationOption chosen by the user.
+ string selected_option_id = 2;
+ // Included if the user modifies the proposed change.
+ // The type should correspond to the original ConfirmationRequest details.
+ oneof modified_details {
+ // Corresponds to a FileDiff confirmation
+ ModifiedFileDetails file_details = 3;
+ }
+}
+
+message ModifiedFileDetails {
+ // The new content after user edits.
+ string new_content = 1;
+}
+```
+
+### 3.3 Method Definitions
+
+This section defines the new methods introduced by the `development-tool`
+extension.
+
+**Method: `commands/get`**
+
+This method allows the client to discover slash commands supported by Gemini
+CLI. The client should call this method during startup to dynamically populate
+its command list.
+
+```proto
+// Response message containing the list of all top-level slash commands.
+message GetAllSlashCommandsResponse {
+ // A list of the top-level slash commands.
+ repeated SlashCommand commands = 1;
+}
+
+// Represents a single slash command, which can contain subcommands.
+message SlashCommand {
+ // The primary name of the command.
+ string name = 1;
+ // A detailed description of what the command does.
+ string description = 2;
+ // A list of arguments that the command accepts.
+ repeated SlashCommandArgument arguments = 3;
+ // A list of nested subcommands.
+ repeated SlashCommand sub_commands = 4;
+}
+
+// Defines the structure for a single slash command argument.
+message SlashCommandArgument {
+ // The name of the argument.
+ string name = 1;
+ // A brief description of what the argument is for.
+ string description = 2;
+ // Whether the argument is required or optional.
+ bool is_required = 3;
+}
+```
+
+**Method: `command/execute`**
+
+This method allows the client to execute a slash command. Following the initial
+`ExecuteSlashCommandResponse`, the agent will use the standard streaming
+mechanism to communicate the command's progress and output. All subsequent
+updates, including textual output, agent thoughts, and any required user
+confirmations for tool calls (like executing a shell command), will be sent as
+`TaskStatusUpdateEvent` messages, re-using the schemas defined above.
+
+```proto
+// Request to execute a specific slash command.
+message ExecuteSlashCommandRequest {
+ // The path to the command, e.g., ["memory", "list"] for /memory list
+ repeated string command_path = 1;
+ // The arguments for the command as a single string.
+ string args = 2;
+}
+
+// Enum for the initial status of a command execution request.
+enum CommandExecutionStatus {
+ // Default unspecified status.
+ COMMAND_EXECUTION_STATUS_UNSPECIFIED = 0;
+ // The command was successfully received and its execution has started.
+ STARTED = 1;
+ // The command failed to start (e.g., command not found, invalid format).
+ FAILED_TO_START = 2;
+ // The command has been paused and is waiting for the user to confirm
+ // a set of shell commands.
+ AWAITING_SHELL_CONFIRMATION = 3;
+ // The command has been paused and is waiting for the user to confirm
+ // a specific action.
+ AWAITING_ACTION_CONFIRMATION = 4;
+}
+
+// The immediate, async response after requesting a command execution.
+message ExecuteSlashCommandResponse {
+ // A unique taskID for this specific command execution.
+ string execution_id = 1;
+ // The initial status of the command execution.
+ CommandExecutionStatus status = 2;
+ // An optional message, particularly useful for explaining why a command
+ // failed to start.
+ string message = 3;
+}
+```
+
+## 4. Separation of Concerns
+
+We believe that all client-side context (ex., workspace state) and client-side
+tool execution (ex. read active buffers) should be routed through MCP.
+
+This approach enforces a strict separation of concerns: the A2A
+`development-tool` extension standardizes communication to the agent, while MCP
+serves as the single, authoritative interface for client-side capabilities.
+
+## Appendix
+
+### A. Example Interaction Flow
+
+1. **Client -> Server**: The client sends a `message/stream` request containing
+ the initial prompt and configuration in an `AgentSettings` object.
+2. **Server -> Client**: SSE stream begins.
+ - **Event 1**: The server sends a `Task` object with
+ `status.state: 'submitted'` and the new `taskId`.
+ - **Event 2**: The server sends a `TaskStatusUpdateEvent` with the metadata
+ `kind` set to `'STATE_CHANGE'` and `status.state` set to `'working'`.
+3. **Agent Logic**: The agent processes the prompt and decides to call the
+ `write_file` tool, which requires user confirmation.
+4. **Server -> Client**:
+ - **Event 3**: The server sends a `TaskStatusUpdateEvent`. The metadata
+ `kind` is `'TOOL_CALL_UPDATE'`, and the `DataPart` contains a `ToolCall`
+ object with its `status` as `'PENDING'` and a populated
+ `confirmation_request`.
+ - **Event 4**: The server sends a final `TaskStatusUpdateEvent` for this
+ exchange. The metadata `kind` is `'STATE_CHANGE'`, the `status.state` is
+ `'input-required'`, and `final` is `true`. The stream for this request
+ ends.
+5. **Client**: The client UI renders the confirmation prompt based on the
+ `ToolCall` object from Event 3. The user clicks "Approve."
+6. **Client -> Server**: The client sends a new `message/stream` request. It
+ includes the `taskId` from the ongoing task and a `DataPart` containing a
+ `ToolCallConfirmation` object (e.g.,
+ `{"tool_call_id": "...", "selected_option_id": "proceed_once"}`).
+7. **Server -> Client**: A new SSE stream begins for the second request.
+ - **Event 1**: The server sends a `TaskStatusUpdateEvent` with
+ `kind: 'TOOL_CALL_UPDATE'`, containing the `ToolCall` object with its
+ `status` now set to `'EXECUTING'`.
+ - **Event 2**: After the tool runs, the server sends another
+ `TaskStatusUpdateEvent` with `kind: 'TOOL_CALL_UPDATE'`, containing the
+ `ToolCall` with its `status` as `'SUCCEEDED'`.
+8. **Agent Logic**: The agent receives the successful tool result and generates
+ a final textual response.
+9. **Server -> Client**:
+ - **Event 3**: The server sends a `TaskStatusUpdateEvent` with
+ `kind: 'TEXT_CONTENT'` and a `TextPart` containing the agent's final
+ answer.
+ - **Event 4**: The server sends the final `TaskStatusUpdateEvent`. The
+ `kind` is `'STATE_CHANGE'`, the `status.state` is `'completed'`, and
+ `final` is `true`. The stream ends.
+10. **Client**: The client displays the final answer. The task is now complete
+ but can be continued by sending another message with the same `taskId`.
diff --git a/packages/a2a-server/index.ts b/packages/a2a-server/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3e74d6beda7ba356f3b3fd22540a54c4fba43468
--- /dev/null
+++ b/packages/a2a-server/index.ts
@@ -0,0 +1,7 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export * from './src/index.js';
diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..87c4c2f86b2e01a63df1386e81468e9fc96dc10d
--- /dev/null
+++ b/packages/a2a-server/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "@google/gemini-cli-a2a-server",
+ "version": "0.62.0-nightly.20260915.gae28844fb",
+ "description": "Gemini CLI A2A Server",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/google-gemini/gemini-cli.git",
+ "directory": "packages/a2a-server"
+ },
+ "type": "module",
+ "main": "dist/index.js",
+ "bin": {
+ "gemini-cli-a2a-server": "dist/a2a-server.mjs"
+ },
+ "scripts": {
+ "build": "node ../../scripts/build_package.js",
+ "start": "node dist/src/http/server.js",
+ "lint": "eslint . --ext .ts,.tsx",
+ "format": "prettier --write .",
+ "test": "vitest run",
+ "test:ci": "vitest run --coverage",
+ "typecheck": "tsc --noEmit"
+ },
+ "files": [
+ "dist"
+ ],
+ "dependencies": {
+ "@a2a-js/sdk": "0.3.11",
+ "@google-cloud/storage": "7.19.0",
+ "@google/gemini-cli-core": "file:../core",
+ "express": "5.1.0",
+ "fs-extra": "11.3.0",
+ "strip-json-comments": "3.1.1",
+ "tar": "7.5.8",
+ "uuid": "13.0.0",
+ "winston": "3.17.0"
+ },
+ "devDependencies": {
+ "@google/genai": "1.30.0",
+ "@types/express": "5.0.3",
+ "@types/fs-extra": "11.0.4",
+ "@types/supertest": "6.0.3",
+ "@types/tar": "6.1.13",
+ "dotenv": "16.4.5",
+ "supertest": "7.1.4",
+ "typescript": "5.8.3",
+ "vitest": "3.2.4"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
diff --git a/packages/a2a-server/src/agent/executor.test.ts b/packages/a2a-server/src/agent/executor.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bb37cc46f8416531623815ec8e4f31cf044ab32e
--- /dev/null
+++ b/packages/a2a-server/src/agent/executor.test.ts
@@ -0,0 +1,587 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
+import { CoderAgentExecutor } from './executor.js';
+import type {
+ ExecutionEventBus,
+ RequestContext,
+ TaskStore,
+} from '@a2a-js/sdk/server';
+import { EventEmitter } from 'node:events';
+import { requestStorage } from '../http/requestStorage.js';
+
+vi.mock('../utils/path_utils.js', () => ({
+ validateWorkspacePath: vi
+ .fn()
+ .mockImplementation(async (path?: string) => path || process.cwd()),
+}));
+
+// Mocks for constructor dependencies
+vi.mock('@google/gemini-cli-core', () => ({
+ GeminiEventType: {
+ PRIMARY_TURN_STARTED: 'PRIMARY_TURN_STARTED',
+ SECONDARY_TURN_STARTED: 'SECONDARY_TURN_STARTED',
+ },
+ SimpleExtensionLoader: vi.fn(),
+ checkPathTrust: vi.fn().mockReturnValue({ isTrusted: false }),
+ isHeadlessMode: vi.fn().mockReturnValue(true),
+ resolveToRealPath: vi.fn().mockImplementation((p) => p),
+}));
+
+vi.mock('../config/config.js', () => ({
+ loadConfig: vi.fn().mockReturnValue({
+ getSessionId: () => 'test-session',
+ getTargetDir: () => '/tmp',
+ getCheckpointingEnabled: () => false,
+ }),
+ loadEnvironment: vi.fn(),
+ setIsTrusted: vi.fn().mockReturnValue(false),
+ setTargetDir: vi.fn().mockReturnValue('/tmp'),
+ envStorage: {
+ run: (env: Record, cb: () => unknown) => cb(),
+ },
+ cwdSymbol: Symbol('cwd'),
+}));
+
+vi.mock('../config/settings.js', () => ({
+ loadSettings: vi.fn().mockReturnValue({}),
+}));
+
+vi.mock('../config/extension.js', () => ({
+ loadExtensions: vi.fn().mockReturnValue([]),
+}));
+
+vi.mock('../http/requestStorage.js', () => ({
+ requestStorage: {
+ getStore: vi.fn(),
+ },
+}));
+
+vi.mock('./task.js', () => {
+ const mockTaskInstance = (taskId: string, contextId: string) => ({
+ id: taskId,
+ contextId,
+ taskState: 'working',
+ acceptUserMessage: vi
+ .fn()
+ .mockImplementation(async function* (context, aborted) {
+ const isConfirmation = (
+ context.userMessage.parts as Array<{ kind: string }>
+ ).some((p) => p.kind === 'confirmation');
+ // Hang only for main user messages (text), allow confirmations to finish quickly
+ if (!isConfirmation && aborted) {
+ await new Promise((resolve) => {
+ aborted.addEventListener('abort', resolve, { once: true });
+ });
+ }
+ yield { type: 'content', value: 'hello' };
+ }),
+ acceptAgentMessage: vi.fn().mockResolvedValue(undefined),
+ scheduleToolCalls: vi.fn().mockResolvedValue(undefined),
+ waitForPendingTools: vi.fn().mockResolvedValue(undefined),
+ getAndClearCompletedTools: vi.fn().mockReturnValue([]),
+ get hasPendingTools() {
+ return false;
+ },
+ get pendingToolsCount() {
+ return 0;
+ },
+ addToolResponsesToHistory: vi.fn(),
+ sendCompletedToolsToLlm: vi.fn().mockImplementation(async function* () {}),
+ cancelPendingTools: vi.fn(),
+ setTaskStateAndPublishUpdate: vi.fn(),
+ dispose: vi.fn(),
+ getMetadata: vi.fn().mockResolvedValue({}),
+ geminiClient: {
+ initialize: vi.fn().mockResolvedValue(undefined),
+ },
+ toSDKTask: () => ({
+ id: taskId,
+ contextId,
+ kind: 'task',
+ status: { state: 'working', timestamp: new Date().toISOString() },
+ metadata: {},
+ history: [],
+ artifacts: [],
+ }),
+ });
+
+ const MockTask = vi.fn().mockImplementation(mockTaskInstance);
+ (MockTask as unknown as { create: Mock }).create = vi
+ .fn()
+ .mockImplementation(async (taskId: string, contextId: string) =>
+ mockTaskInstance(taskId, contextId),
+ );
+
+ return { Task: MockTask };
+});
+
+describe('CoderAgentExecutor', () => {
+ let executor: CoderAgentExecutor;
+ let mockTaskStore: TaskStore;
+ let mockEventBus: ExecutionEventBus;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockTaskStore = {
+ save: vi.fn().mockResolvedValue(undefined),
+ load: vi.fn().mockResolvedValue(undefined),
+ delete: vi.fn().mockResolvedValue(undefined),
+ list: vi.fn().mockResolvedValue([]),
+ } as unknown as TaskStore;
+
+ mockEventBus = new EventEmitter() as unknown as ExecutionEventBus;
+ mockEventBus.publish = vi.fn();
+ mockEventBus.finished = vi.fn();
+
+ executor = new CoderAgentExecutor(mockTaskStore);
+ });
+
+ it('should distinguish between primary and secondary execution', async () => {
+ const taskId = 'test-task';
+ const contextId = 'test-context';
+
+ const mockSocket = new EventEmitter();
+ const requestContext = {
+ userMessage: {
+ messageId: 'msg-1',
+ taskId,
+ contextId,
+ parts: [{ kind: 'text', text: 'hi' }],
+ metadata: {
+ coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
+ },
+ },
+ } as unknown as RequestContext;
+
+ // Mock requestStorage for primary
+ (requestStorage.getStore as Mock).mockReturnValue({
+ req: { socket: mockSocket },
+ });
+
+ // First execution (Primary)
+ const primaryPromise = executor.execute(requestContext, mockEventBus);
+
+ // Give it enough time to reach line 490 in executor.ts
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ expect(
+ (
+ executor as unknown as { executingTasks: Set }
+ ).executingTasks.has(taskId),
+ ).toBe(true);
+ const wrapper = executor.getTask(taskId);
+ expect(wrapper).toBeDefined();
+
+ // Mock requestStorage for secondary
+ const secondarySocket = new EventEmitter();
+ (requestStorage.getStore as Mock).mockReturnValue({
+ req: { socket: secondarySocket },
+ });
+
+ const secondaryRequestContext = {
+ userMessage: {
+ messageId: 'msg-2',
+ taskId,
+ contextId,
+ parts: [{ kind: 'confirmation', callId: '1', outcome: 'proceed' }],
+ metadata: {
+ coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
+ },
+ },
+ } as unknown as RequestContext;
+
+ const secondaryPromise = executor.execute(
+ secondaryRequestContext,
+ mockEventBus,
+ );
+
+ // Secondary execution should NOT add to executingTasks (already there)
+ // and should return early after its loop
+ await secondaryPromise;
+
+ // Task should still be in executingTasks and NOT disposed
+ expect(
+ (
+ executor as unknown as { executingTasks: Set }
+ ).executingTasks.has(taskId),
+ ).toBe(true);
+ expect(wrapper?.task.dispose).not.toHaveBeenCalled();
+
+ // Now simulate secondary socket closure - it should NOT affect primary
+ secondarySocket.emit('end');
+ expect(
+ (
+ executor as unknown as { executingTasks: Set }
+ ).executingTasks.has(taskId),
+ ).toBe(true);
+ expect(wrapper?.task.dispose).not.toHaveBeenCalled();
+
+ // Set to terminal state to verify disposal on finish
+ wrapper!.task.taskState = 'completed';
+
+ // Now close primary socket
+ mockSocket.emit('end');
+
+ await primaryPromise;
+
+ expect(
+ (
+ executor as unknown as { executingTasks: Set }
+ ).executingTasks.has(taskId),
+ ).toBe(false);
+ expect(wrapper?.task.dispose).toHaveBeenCalled();
+ });
+
+ it('should evict task from cache when it reaches terminal state', async () => {
+ const taskId = 'test-task-terminal';
+ const contextId = 'test-context';
+
+ const mockSocket = new EventEmitter();
+ (requestStorage.getStore as Mock).mockReturnValue({
+ req: { socket: mockSocket },
+ });
+
+ const requestContext = {
+ userMessage: {
+ messageId: 'msg-1',
+ taskId,
+ contextId,
+ parts: [{ kind: 'text', text: 'hi' }],
+ metadata: {
+ coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
+ },
+ },
+ } as unknown as RequestContext;
+
+ const primaryPromise = executor.execute(requestContext, mockEventBus);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ const wrapper = executor.getTask(taskId)!;
+ expect(wrapper).toBeDefined();
+ // Simulate terminal state
+ wrapper.task.taskState = 'completed';
+
+ // Finish primary execution
+ mockSocket.emit('end');
+ await primaryPromise;
+
+ expect(executor.getTask(taskId)).toBeUndefined();
+ expect(wrapper.task.dispose).toHaveBeenCalled();
+ });
+
+ it('should yield the turn and transition to input-required if tools are pending', async () => {
+ const taskId = 'test-task-pending-tools';
+ const contextId = 'test-context';
+
+ const mockSocket = new EventEmitter();
+ (requestStorage.getStore as Mock).mockReturnValue({
+ req: { socket: mockSocket },
+ });
+
+ // Pre-create the task to safely modify its mocked methods before execution
+ const wrapper = await executor.createTask(
+ taskId,
+ contextId,
+ undefined,
+ mockEventBus,
+ );
+ const hasPendingToolsSpy = vi
+ .spyOn(wrapper.task, 'hasPendingTools', 'get')
+ .mockReturnValue(true);
+ vi.spyOn(wrapper.task, 'pendingToolsCount', 'get').mockReturnValue(1);
+
+ const requestContext = {
+ userMessage: {
+ messageId: 'msg-1',
+ taskId,
+ contextId,
+ parts: [{ kind: 'confirmation', callId: '1', outcome: 'proceed' }],
+ metadata: {
+ coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
+ },
+ },
+ } as unknown as RequestContext;
+
+ await executor.execute(requestContext, mockEventBus);
+
+ // Assert that the executor yielded the turn correctly without further progression
+ expect(hasPendingToolsSpy).toHaveBeenCalled();
+ expect(wrapper.task.getAndClearCompletedTools).not.toHaveBeenCalled();
+ expect(wrapper.task.sendCompletedToolsToLlm).not.toHaveBeenCalled();
+ expect(wrapper.task.setTaskStateAndPublishUpdate).toHaveBeenCalledWith(
+ 'input-required',
+ expect.any(Object),
+ undefined,
+ undefined,
+ true,
+ );
+ });
+
+ it('cancelTask should abort the active execution loop', async () => {
+ const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
+ const taskId = 'test-task-to-cancel';
+ const contextId = 'test-context';
+
+ const mockSocket = new EventEmitter();
+ (requestStorage.getStore as Mock).mockReturnValue({
+ req: { socket: mockSocket },
+ });
+
+ const requestContext = {
+ userMessage: {
+ messageId: 'msg-1',
+ taskId,
+ contextId,
+ parts: [{ kind: 'text', text: 'a long running prompt' }],
+ metadata: {
+ coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
+ },
+ },
+ } as unknown as RequestContext;
+
+ // Don't await this, let it run in the background.
+ let primaryError: Error | null = null;
+ const primaryPromise = executor.execute(requestContext, mockEventBus);
+ primaryPromise.catch((err) => {
+ primaryError = err as Error;
+ });
+
+ // Poll until the task is registered in the executor to avoid flaky timeouts in slow CI environments.
+ let attempts = 0;
+ while (!executor.getTask(taskId)) {
+ if (primaryError) {
+ throw new Error(`Primary execution failed early: ${primaryError}`);
+ }
+ if (attempts++ > 100) {
+ // 100 * 5ms = 500ms timeout
+ throw new Error('Timed out waiting for task to be registered');
+ }
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ }
+
+ const wrapper = executor.getTask(taskId);
+ expect(wrapper).toBeDefined();
+ const setTaskStateSpy = vi
+ .spyOn(wrapper!.task, 'setTaskStateAndPublishUpdate')
+ .mockImplementation((newState) => {
+ // Make the mock realistic: actually update the state when called.
+ wrapper!.task.taskState = newState;
+ });
+
+ // Now, cancel the task.
+ await executor.cancelTask(taskId, mockEventBus);
+
+ // Verify that the abort method on the controller was called and state was updated.
+ expect(abortSpy).toHaveBeenCalledOnce();
+ expect(setTaskStateSpy).toHaveBeenCalledWith(
+ 'canceled',
+ expect.any(Object),
+ 'Task canceled by user request.',
+ undefined,
+ true,
+ );
+
+ // Clean up the test by allowing the promise to resolve.
+ // The abort call should have unblocked the acceptUserMessage generator.
+ await primaryPromise;
+
+ // Verify task is evicted from cache
+ expect(executor.getTask(taskId)).toBeUndefined();
+
+ abortSpy.mockRestore();
+ });
+
+ it('cancelTask should explicitly save task state to TaskStore and evict task during active aborts', async () => {
+ const taskId = 'test-task-active-abort-save';
+ const contextId = 'test-context';
+
+ const mockSocket = new EventEmitter();
+ (requestStorage.getStore as Mock).mockReturnValue({
+ req: { socket: mockSocket },
+ });
+
+ const requestContext = {
+ userMessage: {
+ messageId: 'msg-1',
+ taskId,
+ contextId,
+ parts: [{ kind: 'text', text: 'a long running prompt' }],
+ metadata: {
+ coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
+ },
+ },
+ } as unknown as RequestContext;
+
+ const primaryPromise = executor.execute(requestContext, mockEventBus);
+
+ // Wait for task to be registered
+ let attempts = 0;
+ while (!executor.getTask(taskId)) {
+ if (attempts++ > 100) {
+ throw new Error('Timed out waiting for task to be registered');
+ }
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ }
+
+ const wrapper = executor.getTask(taskId)!;
+ const saveSpy = vi.spyOn(mockTaskStore, 'save');
+
+ // Now, cancel the task.
+ await executor.cancelTask(taskId, mockEventBus);
+
+ // Verify that the task state was saved to TaskStore during cancelTask
+ expect(saveSpy).toHaveBeenCalled();
+ expect(wrapper.task.dispose).toHaveBeenCalled();
+ expect(executor.getTask(taskId)).toBeUndefined();
+
+ // Clean up the test by allowing the promise to resolve.
+ await primaryPromise;
+ });
+
+ it('should allow executing a task that is in a terminal state by re-activating it', async () => {
+ const taskId = 'test-task-terminal-reactivate';
+ const contextId = 'test-context';
+
+ const mockSocket = new EventEmitter();
+ (requestStorage.getStore as Mock).mockReturnValue({
+ req: { socket: mockSocket },
+ });
+
+ const requestContext = {
+ userMessage: {
+ messageId: 'msg-1',
+ taskId,
+ contextId,
+ parts: [{ kind: 'text', text: 'hi' }],
+ metadata: {
+ coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
+ },
+ },
+ } as unknown as RequestContext;
+
+ // Create a task wrapper and set it to a terminal state in cache
+ const wrapper = await executor.createTask(
+ taskId,
+ contextId,
+ undefined,
+ mockEventBus,
+ );
+ wrapper.task.taskState = 'canceled';
+
+ // Now execute it again
+ const primaryPromise = executor.execute(requestContext, mockEventBus);
+
+ // Wait for task to be re-activated in the async execute loop
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ // Verify task was re-activated to 'submitted' / 'working' and executed instead of being ignored
+ const runningWrapper = executor.getTask(taskId);
+ expect(runningWrapper).toBeDefined();
+ expect(runningWrapper!.task.taskState).not.toBe('canceled');
+
+ // Clean up the test execution loop
+ mockSocket.emit('end');
+ await primaryPromise;
+ });
+
+ it('should not evict or treat a new request as secondary when preceding request is canceled and winding down', async () => {
+ const taskId = 'test-repro-race-condition';
+ const contextId = 'test-context';
+
+ // Simulating real-world TaskStore save delay / I/O latency
+ vi.spyOn(mockTaskStore, 'save').mockImplementation(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ });
+
+ const mockSocket1 = new EventEmitter();
+ (requestStorage.getStore as Mock).mockReturnValue({
+ req: { socket: mockSocket1 },
+ });
+
+ const requestContext1 = {
+ userMessage: {
+ messageId: 'msg-1',
+ taskId,
+ contextId,
+ parts: [{ kind: 'text', text: 'prompt 1' }],
+ metadata: {
+ coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
+ },
+ },
+ } as unknown as RequestContext;
+
+ const primaryPromise = executor.execute(requestContext1, mockEventBus);
+
+ // Wait for the task to be registered
+ let attempts = 0;
+ while (!executor.getTask(taskId)) {
+ if (attempts++ > 100) {
+ throw new Error('Timed out waiting for task to be registered');
+ }
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ }
+
+ const wrapper1 = executor.getTask(taskId)!;
+ expect(wrapper1).toBeDefined();
+
+ // Mock setTaskStateAndPublishUpdate realistically to trigger the buggy eviction behavior
+ vi.spyOn(wrapper1.task, 'setTaskStateAndPublishUpdate').mockImplementation(
+ (newState) => {
+ wrapper1.task.taskState = newState;
+ },
+ );
+
+ // Cancel the task (Request 1)
+ await executor.cancelTask(taskId, mockEventBus);
+
+ // Now immediately start Request 2 with the same taskId
+ const mockSocket2 = new EventEmitter();
+ (requestStorage.getStore as Mock).mockReturnValue({
+ req: { socket: mockSocket2 },
+ });
+
+ const requestContext2 = {
+ userMessage: {
+ messageId: 'msg-2',
+ taskId,
+ contextId,
+ parts: [{ kind: 'text', text: 'prompt 2' }],
+ metadata: {
+ coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
+ },
+ },
+ } as unknown as RequestContext;
+
+ const secondaryPromise = executor.execute(requestContext2, mockEventBus);
+
+ let secondaryResolved = false;
+ void secondaryPromise
+ .then(() => {
+ secondaryResolved = true;
+ })
+ .catch(() => {});
+
+ // Give it a moment to initialize Request 2's task in execute loop
+ await new Promise((resolve) => setTimeout(resolve, 20));
+
+ // Request 2 should NOT have resolved prematurely (it should be running as a primary loop, not secondary)
+ expect(secondaryResolved).toBe(false);
+
+ // Await primaryPromise to let Request 1 completely wind down
+ await primaryPromise;
+
+ // Verify task 2 was NOT evicted/destroyed
+ const wrapper2 = executor.getTask(taskId);
+ expect(wrapper2).toBeDefined();
+ expect(wrapper2).not.toBe(wrapper1);
+
+ // Clean up Request 2
+ mockSocket2.emit('end');
+ await secondaryPromise;
+ });
+});
diff --git a/packages/a2a-server/src/agent/executor.ts b/packages/a2a-server/src/agent/executor.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b300a5bb912eb2870233efccc173fad95d711354
--- /dev/null
+++ b/packages/a2a-server/src/agent/executor.ts
@@ -0,0 +1,939 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { Task as SDKTask } from '@a2a-js/sdk';
+import type {
+ TaskStore,
+ AgentExecutor,
+ AgentExecutionEvent,
+ RequestContext,
+ ExecutionEventBus,
+} from '@a2a-js/sdk/server';
+import {
+ GeminiEventType,
+ SimpleExtensionLoader,
+ type ToolCallRequestInfo,
+ type Config,
+ resolveToRealPath,
+} from '@google/gemini-cli-core';
+import { v4 as uuidv4 } from 'uuid';
+
+import { logger } from '../utils/logger.js';
+import {
+ CoderAgentEvent,
+ getPersistedState,
+ setPersistedState,
+ type StateChange,
+ type AgentSettings,
+ type PersistedStateMetadata,
+ getContextIdFromMetadata,
+ getAgentSettingsFromMetadata,
+} from '../types.js';
+import {
+ loadConfig,
+ setTargetDir,
+ setIsTrusted,
+ envStorage,
+ cwdSymbol,
+ loadEnvironment,
+ type TaskEnv,
+} from '../config/config.js';
+import { loadSettings } from '../config/settings.js';
+import { loadExtensions } from '../config/extension.js';
+import { Task } from './task.js';
+import { requestStorage } from '../http/requestStorage.js';
+import { pushTaskStateFailed } from '../utils/executor_utils.js';
+
+function validateWorkspacePath(workspacePath?: string): string {
+ const trimmed = workspacePath?.trim();
+ if (!trimmed) {
+ return process.env['CODER_AGENT_WORKSPACE_PATH'] ?? process.cwd();
+ }
+ return resolveToRealPath(trimmed);
+}
+
+/**
+ * Provides a wrapper for Task. Passes data from Task to SDKTask.
+ * The idea is to use this class inside CoderAgentExecutor to replace Task.
+ */
+class TaskWrapper {
+ task: Task;
+ agentSettings: AgentSettings;
+
+ constructor(task: Task, agentSettings: AgentSettings) {
+ this.task = task;
+ this.agentSettings = agentSettings;
+ }
+
+ get id() {
+ return this.task.id;
+ }
+
+ toSDKTask(): SDKTask {
+ const persistedState: PersistedStateMetadata = {
+ _agentSettings: this.agentSettings,
+ _taskState: this.task.taskState,
+ };
+
+ const sdkTask: SDKTask = {
+ id: this.task.id,
+ contextId: this.task.contextId,
+ kind: 'task',
+ status: {
+ state: this.task.taskState,
+ timestamp: new Date().toISOString(),
+ },
+ metadata: setPersistedState({}, persistedState),
+ history: [],
+ artifacts: [],
+ };
+ sdkTask.metadata!['_contextId'] = this.task.contextId;
+ return sdkTask;
+ }
+}
+
+/**
+ * CoderAgentExecutor implements the agent's core logic for code generation.
+ */
+export class CoderAgentExecutor implements AgentExecutor {
+ private tasks: Map = new Map();
+ // Track tasks with an active execution loop.
+ private executingTasks = new Map();
+ private activeAbortControllers = new Map>();
+ // Track tasks currently initializing to prevent race conditions.
+ private initializingTasks = new Set();
+ private initializationPromises = new Map>();
+ // Track explicitly canceled abort controllers to handle cancellation during initialization.
+ private explicitlyCanceledTasks = new Set();
+
+ constructor(private taskStore?: TaskStore) {}
+
+ private async getConfigWithEnv(
+ agentSettings: AgentSettings,
+ taskId: string,
+ isTrusted: boolean,
+ workspaceRoot: string,
+ ): Promise {
+ const settings = loadSettings(workspaceRoot, isTrusted);
+ const extensions = loadExtensions(workspaceRoot, isTrusted);
+ return loadConfig(
+ settings,
+ new SimpleExtensionLoader(extensions),
+ taskId,
+ isTrusted,
+ workspaceRoot,
+ );
+ }
+
+ private async runInIsolatedEnv(
+ agentSettings: AgentSettings,
+ fn: (isTrusted: boolean, workspaceRoot: string) => Promise,
+ ): Promise {
+ const workspaceRoot = await setTargetDir(agentSettings);
+ const isTrusted = setIsTrusted(agentSettings, workspaceRoot);
+
+ const envVars: TaskEnv = { ...process.env };
+ envVars['GEMINI_CLI_TRUST_WORKSPACE'] = isTrusted ? 'true' : 'false';
+ envVars[cwdSymbol] = workspaceRoot;
+
+ return envStorage.run(envVars, async () => {
+ const loadedEnv = await loadEnvironment(isTrusted, workspaceRoot);
+ Object.assign(envVars, loadedEnv);
+ return fn(isTrusted, workspaceRoot);
+ });
+ }
+
+ private cleanupAndEvictTask(taskId: string, expectedWrapper?: TaskWrapper) {
+ const wrapper = this.tasks.get(taskId);
+ if (wrapper) {
+ if (expectedWrapper && wrapper !== expectedWrapper) {
+ logger.info(
+ `[CoderAgentExecutor] cleanupAndEvictTask: wrapper mismatch for task ${taskId}. Skipping eviction.`,
+ );
+ return;
+ }
+ wrapper.task.dispose();
+ this.tasks.delete(taskId);
+ }
+ }
+
+ /**
+ * Reconstructs TaskWrapper from SDKTask.
+ */
+ async reconstruct(
+ sdkTask: SDKTask,
+ eventBus?: ExecutionEventBus,
+ ): Promise {
+ const metadata = sdkTask.metadata || {};
+ const persistedState = getPersistedState(metadata);
+
+ if (!persistedState) {
+ throw new Error(
+ `Cannot reconstruct task ${sdkTask.id}: missing persisted state in metadata.`,
+ );
+ }
+
+ let agentSettings: AgentSettings;
+ try {
+ agentSettings = {
+ ...(persistedState._agentSettings ?? {}),
+ workspacePath: validateWorkspacePath(
+ persistedState._agentSettings?.workspacePath,
+ ),
+ isTrusted: false,
+ };
+ } catch (error) {
+ logger.error(
+ `[CoderAgentExecutor] Invalid workspace path in persisted state for task ${sdkTask.id}:`,
+ error,
+ );
+ if (eventBus) {
+ void pushTaskStateFailed(
+ error,
+ eventBus,
+ sdkTask.id,
+ sdkTask.contextId,
+ );
+ }
+ throw error; // Re-throw to be caught by caller
+ }
+
+ return this.runInIsolatedEnv(
+ agentSettings,
+ async (isTrusted, workspaceRoot) => {
+ const config = await this.getConfigWithEnv(
+ agentSettings,
+ sdkTask.id,
+ isTrusted,
+ workspaceRoot,
+ );
+ const contextId: string =
+ getContextIdFromMetadata(metadata) || sdkTask.contextId;
+ const runtimeTask = await Task.create(
+ sdkTask.id,
+ contextId,
+ config,
+ eventBus,
+ agentSettings.autoExecute,
+ );
+ runtimeTask.taskState = persistedState._taskState;
+ await runtimeTask.geminiClient.initialize();
+
+ const wrapper = new TaskWrapper(runtimeTask, agentSettings);
+ this.tasks.set(sdkTask.id, wrapper);
+ logger.info(`Task ${sdkTask.id} reconstructed from store.`);
+ return wrapper;
+ },
+ );
+ }
+
+ async createTask(
+ taskId: string,
+ contextId: string,
+ agentSettingsInput?: AgentSettings,
+ eventBus?: ExecutionEventBus,
+ ): Promise {
+ const agentSettings: AgentSettings = agentSettingsInput || {
+ kind: CoderAgentEvent.StateAgentSettingsEvent,
+ workspacePath: process.cwd(),
+ };
+ return this.runInIsolatedEnv(
+ agentSettings,
+ async (isTrusted, workspaceRoot) => {
+ const config = await this.getConfigWithEnv(
+ agentSettings,
+ taskId,
+ isTrusted,
+ workspaceRoot,
+ );
+ const runtimeTask = await Task.create(
+ taskId,
+ contextId,
+ config,
+ eventBus,
+ agentSettings.autoExecute,
+ );
+ await runtimeTask.geminiClient.initialize();
+
+ const wrapper = new TaskWrapper(runtimeTask, agentSettings);
+ this.tasks.set(taskId, wrapper);
+ logger.info(`New task ${taskId} created.`);
+ return wrapper;
+ },
+ );
+ }
+
+ getTask(taskId: string): TaskWrapper | undefined {
+ return this.tasks.get(taskId);
+ }
+
+ getAllTasks(): TaskWrapper[] {
+ return Array.from(this.tasks.values());
+ }
+
+ cancelTask = async (
+ taskId: string,
+ eventBus: ExecutionEventBus,
+ ): Promise => {
+ logger.info(
+ `[CoderAgentExecutor] Received cancel request for task ${taskId}`,
+ );
+
+ const abortControllers = this.activeAbortControllers.get(taskId);
+ if (abortControllers && abortControllers.size > 0) {
+ logger.info(
+ `[CoderAgentExecutor] Aborting ${abortControllers.size} active execution loop(s) for task ${taskId}.`,
+ );
+ // Abort first to ensure loops are stopped.
+ for (const controller of Array.from(abortControllers)) {
+ this.explicitlyCanceledTasks.add(controller);
+ controller.abort();
+ }
+
+ // Then, attempt to update state and persist.
+ const wrapper = this.tasks.get(taskId);
+ if (wrapper) {
+ const { task } = wrapper;
+ task.cancelPendingTools('Task canceled by user request.');
+ task.setTaskStateAndPublishUpdate(
+ 'canceled',
+ { kind: CoderAgentEvent.StateChangeEvent },
+ 'Task canceled by user request.',
+ undefined,
+ true,
+ );
+ this.cleanupAndEvictTask(taskId, wrapper);
+ try {
+ await this.taskStore?.save(wrapper.toSDKTask());
+ logger.info(
+ `[CoderAgentExecutor] Task ${taskId} state CANCELED saved during active abort.`,
+ );
+ } catch (saveError) {
+ logger.error(
+ `[CoderAgentExecutor] Failed to save task ${taskId} state during active abort:`,
+ saveError,
+ );
+ }
+ }
+ return;
+ }
+
+ // If there is no active execution loop, the task is idle.
+ // We can clean it up directly.
+ logger.info(
+ `[CoderAgentExecutor] No active execution for task ${taskId}. Cleaning up directly.`,
+ );
+ const wrapper = this.tasks.get(taskId);
+
+ if (!wrapper) {
+ logger.warn(
+ `[CoderAgentExecutor] Task ${taskId} not found for cancellation.`,
+ );
+ eventBus.publish({
+ kind: 'status-update',
+ taskId,
+ contextId: uuidv4(),
+ status: {
+ state: 'failed',
+ message: {
+ kind: 'message',
+ role: 'agent',
+ parts: [{ kind: 'text', text: `Task ${taskId} not found.` }],
+ messageId: uuidv4(),
+ taskId,
+ },
+ },
+ final: true,
+ });
+ return;
+ }
+
+ const { task } = wrapper;
+
+ if (task.taskState === 'canceled' || task.taskState === 'failed') {
+ logger.info(
+ `[CoderAgentExecutor] Task ${taskId} is already in a final state: ${task.taskState}. No action needed for cancellation.`,
+ );
+ eventBus.publish({
+ kind: 'status-update',
+ taskId,
+ contextId: task.contextId,
+ status: {
+ state: task.taskState,
+ message: {
+ kind: 'message',
+ role: 'agent',
+ parts: [
+ {
+ kind: 'text',
+ text: `Task ${taskId} is already ${task.taskState}.`,
+ },
+ ],
+ messageId: uuidv4(),
+ taskId,
+ },
+ },
+ final: true,
+ });
+ return;
+ }
+
+ try {
+ logger.info(
+ `[CoderAgentExecutor] Initiating cancellation for task ${taskId}.`,
+ );
+ task.cancelPendingTools('Task canceled by user request.');
+
+ const stateChange: StateChange = {
+ kind: CoderAgentEvent.StateChangeEvent,
+ };
+ task.setTaskStateAndPublishUpdate(
+ 'canceled',
+ stateChange,
+ 'Task canceled by user request.',
+ undefined,
+ true,
+ );
+ logger.info(
+ `[CoderAgentExecutor] Task ${taskId} cancellation processed. Saving state.`,
+ );
+ // Cleanup listener subscriptions and evict synchronously before async save
+ this.cleanupAndEvictTask(taskId, wrapper);
+
+ await this.taskStore?.save(wrapper.toSDKTask());
+ logger.info(`[CoderAgentExecutor] Task ${taskId} state CANCELED saved.`);
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : 'Unknown error';
+ logger.error(
+ `[CoderAgentExecutor] Error during task cancellation for ${taskId}: ${errorMessage}`,
+ error,
+ );
+ eventBus.publish({
+ kind: 'status-update',
+ taskId,
+ contextId: task.contextId,
+ status: {
+ state: 'failed',
+ message: {
+ kind: 'message',
+ role: 'agent',
+ parts: [
+ {
+ kind: 'text',
+ text: `Failed to process cancellation for task ${taskId}: ${errorMessage}`,
+ },
+ ],
+ messageId: uuidv4(),
+ taskId,
+ },
+ },
+ final: true,
+ });
+ }
+ };
+
+ async execute(
+ requestContext: RequestContext,
+ eventBus: ExecutionEventBus,
+ ): Promise {
+ const userMessage = requestContext.userMessage;
+ const sdkTask = requestContext.task;
+
+ const taskId = sdkTask?.id || userMessage.taskId || uuidv4();
+ const contextId: string =
+ userMessage.contextId ||
+ sdkTask?.contextId ||
+ getContextIdFromMetadata(sdkTask?.metadata) ||
+ uuidv4();
+
+ let agentSettings: AgentSettings;
+ try {
+ const existingWrapper = this.tasks.get(taskId);
+ if (existingWrapper) {
+ agentSettings = existingWrapper.agentSettings;
+ } else if (sdkTask) {
+ const persistedState = getPersistedState(sdkTask.metadata || {});
+ agentSettings = {
+ kind: CoderAgentEvent.StateAgentSettingsEvent,
+ ...(persistedState?._agentSettings ?? {}),
+ workspacePath: validateWorkspacePath(
+ persistedState?._agentSettings?.workspacePath,
+ ),
+ isTrusted: false,
+ };
+ } else {
+ const rawAgentSettings = getAgentSettingsFromMetadata(
+ userMessage.metadata,
+ );
+ agentSettings = {
+ kind: CoderAgentEvent.StateAgentSettingsEvent,
+ ...(rawAgentSettings || {}),
+ workspacePath: validateWorkspacePath(rawAgentSettings?.workspacePath),
+ isTrusted: false,
+ };
+ }
+ } catch (error) {
+ logger.error(
+ `[CoderAgentExecutor] Invalid workspace path during execution setup for task ${taskId}:`,
+ error,
+ );
+ if (eventBus) {
+ void pushTaskStateFailed(error, eventBus, taskId, contextId);
+ }
+ throw error;
+ }
+
+ return this.runInIsolatedEnv(agentSettings, async () => {
+ try {
+ logger.info(
+ `[CoderAgentExecutor] Executing for taskId: ${taskId}, contextId: ${contextId}`,
+ );
+ logger.info(
+ `[CoderAgentExecutor] userMessage: ${JSON.stringify(userMessage)}`,
+ );
+ eventBus.on('event', (event: AgentExecutionEvent) =>
+ logger.info('[EventBus event]: ', event),
+ );
+
+ const store = requestStorage.getStore();
+ if (!store) {
+ logger.error(
+ '[CoderAgentExecutor] Could not get request from async local storage. Cancellation on socket close will not be handled for this request.',
+ );
+ }
+
+ const abortController = new AbortController();
+ const abortSignal = abortController.signal;
+
+ if (!this.activeAbortControllers.has(taskId)) {
+ this.activeAbortControllers.set(taskId, new Set());
+ }
+ this.activeAbortControllers.get(taskId)!.add(abortController);
+
+ let proceedToMainLoop = false;
+ let wrapper: TaskWrapper | undefined;
+ let isPrimaryExecution = false;
+
+ try {
+ if (store) {
+ const socket = store.req.socket;
+ const onSocketEnd = () => {
+ logger.info(
+ `[CoderAgentExecutor] Socket ended for message ${userMessage.messageId} (task ${taskId}). Aborting execution loop.`,
+ );
+ if (!abortController.signal.aborted) {
+ abortController.abort();
+ }
+ socket.removeListener('end', onSocketEnd);
+ };
+ socket.on('end', onSocketEnd);
+ socket.once('close', () =>
+ socket.removeListener('end', onSocketEnd),
+ );
+ abortSignal.addEventListener('abort', () =>
+ socket.removeListener('end', onSocketEnd),
+ );
+ logger.info(
+ `[CoderAgentExecutor] Socket close handler set up for task ${taskId}.`,
+ );
+ }
+
+ // Check if the task is currently initializing
+ if (this.initializingTasks.has(taskId)) {
+ logger.info(
+ `[CoderAgentExecutor] Task ${taskId} is currently initializing. Waiting for initialization to complete.`,
+ );
+ const initPromise = this.initializationPromises.get(taskId);
+ if (initPromise) {
+ try {
+ wrapper = await initPromise;
+ } catch {
+ logger.error(
+ `[CoderAgentExecutor] Failed to wait for task ${taskId} initialization.`,
+ );
+ return;
+ }
+ }
+ }
+
+ if (!wrapper) {
+ this.initializingTasks.add(taskId);
+ const initPromise = (async () => {
+ let initializedWrapper: TaskWrapper | undefined;
+ initializedWrapper = this.tasks.get(taskId);
+
+ if (initializedWrapper) {
+ initializedWrapper.task.eventBus = eventBus;
+ logger.info(
+ `[CoderAgentExecutor] Task ${taskId} found in memory cache.`,
+ );
+ } else if (sdkTask) {
+ logger.info(
+ `[CoderAgentExecutor] Task ${taskId} found in TaskStore. Reconstructing...`,
+ );
+ try {
+ initializedWrapper = await this.reconstruct(
+ sdkTask,
+ eventBus,
+ );
+ } catch (e) {
+ logger.error(
+ `[CoderAgentExecutor] Aborting execution due to failed task reconstruction for task ${taskId}:`,
+ e,
+ );
+ throw e;
+ }
+ } else {
+ initializedWrapper = await this.createTask(
+ taskId,
+ contextId,
+ agentSettings,
+ eventBus,
+ );
+ const newTaskSDK = initializedWrapper.toSDKTask();
+ eventBus.publish({
+ ...newTaskSDK,
+ kind: 'task',
+ status: {
+ state: 'submitted',
+ timestamp: new Date().toISOString(),
+ },
+ history: [userMessage],
+ });
+ try {
+ await this.taskStore?.save(newTaskSDK);
+ logger.info(
+ `[CoderAgentExecutor] New task ${taskId} saved to store.`,
+ );
+ } catch (saveError) {
+ logger.error(
+ `[CoderAgentExecutor] Failed to save new task ${taskId} to store:`,
+ saveError,
+ );
+ }
+ }
+ return initializedWrapper;
+ })();
+
+ this.initializationPromises.set(taskId, initPromise);
+
+ try {
+ wrapper = await initPromise;
+ } catch {
+ // Error is already handled/logged inside the promise
+ return;
+ } finally {
+ this.initializingTasks.delete(taskId);
+ this.initializationPromises.delete(taskId);
+ }
+ }
+
+ if (!wrapper) {
+ logger.error(
+ `[CoderAgentExecutor] Task ${taskId} is unexpectedly undefined after load/create.`,
+ );
+ return;
+ }
+
+ const currentTask = wrapper.task;
+
+ if (
+ ['canceled', 'failed', 'completed'].includes(currentTask.taskState)
+ ) {
+ logger.info(
+ `[CoderAgentExecutor] Re-activating task ${taskId} from terminal state ${currentTask.taskState}.`,
+ );
+ currentTask.taskState = 'submitted';
+ }
+
+ if (abortSignal.aborted) {
+ logger.warn(
+ `[CoderAgentExecutor] Task ${taskId} was aborted during initialization.`,
+ );
+ const isExplicitCancel =
+ this.explicitlyCanceledTasks.has(abortController);
+ const finalState = isExplicitCancel ? 'canceled' : 'input-required';
+ const message = isExplicitCancel
+ ? 'Task canceled by user request.'
+ : 'Execution aborted by client.';
+ currentTask.setTaskStateAndPublishUpdate(
+ finalState,
+ { kind: CoderAgentEvent.StateChangeEvent },
+ message,
+ undefined,
+ true,
+ );
+ try {
+ await this.taskStore?.save(wrapper.toSDKTask());
+ } catch (saveError) {
+ logger.error(
+ `[CoderAgentExecutor] Failed to save task ${taskId} state:`,
+ saveError,
+ );
+ }
+ if (isExplicitCancel) {
+ this.cleanupAndEvictTask(taskId, wrapper);
+ }
+ return;
+ }
+
+ const activeController = this.executingTasks.get(taskId);
+ if (activeController && !activeController.signal.aborted) {
+ logger.info(
+ `[CoderAgentExecutor] Task ${taskId} has a pending execution. Processing message and yielding.`,
+ );
+ currentTask.eventBus = eventBus;
+ try {
+ for await (const _ of currentTask.acceptUserMessage(
+ requestContext,
+ abortController.signal,
+ )) {
+ logger.info(
+ `[CoderAgentExecutor] Processing user message ${userMessage.messageId} in secondary execution loop for task ${taskId}.`,
+ );
+ }
+ } catch (error) {
+ if (!abortController.signal.aborted) {
+ throw error;
+ }
+ logger.info(
+ `[CoderAgentExecutor] Secondary execution loop for task ${taskId} was aborted.`,
+ );
+ }
+ return;
+ }
+
+ isPrimaryExecution = true;
+
+ proceedToMainLoop = true;
+ } finally {
+ this.explicitlyCanceledTasks.delete(abortController);
+ if (!proceedToMainLoop) {
+ const controllers = this.activeAbortControllers.get(taskId);
+ if (controllers) {
+ controllers.delete(abortController);
+ if (controllers.size === 0) {
+ this.activeAbortControllers.delete(taskId);
+ }
+ }
+ }
+ }
+
+ const currentTask = wrapper.task;
+ try {
+ logger.info(
+ `[CoderAgentExecutor] Starting main execution for message ${userMessage.messageId} for task ${taskId}.`,
+ );
+ this.executingTasks.set(taskId, abortController);
+
+ let agentTurnActive = true;
+ logger.info(
+ `[CoderAgentExecutor] Task ${taskId}: Processing user turn.`,
+ );
+ let agentEvents = currentTask.acceptUserMessage(
+ requestContext,
+ abortSignal,
+ );
+
+ while (agentTurnActive) {
+ if (abortSignal.aborted) {
+ logger.info(
+ `[CoderAgentExecutor] Task ${taskId} aborted before turn. Exiting loop.`,
+ );
+ throw new Error('Execution aborted');
+ }
+ logger.info(
+ `[CoderAgentExecutor] Task ${taskId}: Processing agent turn (LLM stream).`,
+ );
+ const toolCallRequests: ToolCallRequestInfo[] = [];
+ for await (const event of agentEvents) {
+ if (abortSignal.aborted) {
+ logger.warn(
+ `[CoderAgentExecutor] Task ${taskId}: Abort signal received during agent event processing.`,
+ );
+ throw new Error('Execution aborted');
+ }
+ if (event.type === GeminiEventType.ToolCallRequest) {
+ toolCallRequests.push(event.value);
+ continue;
+ }
+ await currentTask.acceptAgentMessage(event);
+ }
+
+ if (abortSignal.aborted) throw new Error('Execution aborted');
+
+ if (toolCallRequests.length > 0) {
+ logger.info(
+ `[CoderAgentExecutor] Task ${taskId}: Found ${toolCallRequests.length} tool call requests. Scheduling as a batch.`,
+ );
+ await currentTask.scheduleToolCalls(
+ toolCallRequests,
+ abortSignal,
+ );
+ }
+
+ logger.info(
+ `[CoderAgentExecutor] Task ${taskId}: Waiting for pending tools if any.`,
+ );
+ await currentTask.waitForPendingTools();
+ logger.info(
+ `[CoderAgentExecutor] Task ${taskId}: All pending tools completed or none were pending.`,
+ );
+
+ if (abortSignal.aborted) throw new Error('Execution aborted');
+
+ if (currentTask.hasPendingTools) {
+ logger.info(
+ `[CoderAgentExecutor] Task ${taskId}: There are still ${currentTask.pendingToolsCount} pending tools waiting for approval. Yielding to user.`,
+ );
+ agentTurnActive = false;
+ } else {
+ const completedTools = currentTask.getAndClearCompletedTools();
+
+ if (completedTools.length > 0) {
+ if (
+ completedTools.every((tool) => tool.status === 'cancelled')
+ ) {
+ logger.info(
+ `[CoderAgentExecutor] Task ${taskId}: All tool calls were cancelled. Updating history and ending agent turn.`,
+ );
+ currentTask.addToolResponsesToHistory(completedTools);
+ agentTurnActive = false;
+ const stateChange: StateChange = {
+ kind: CoderAgentEvent.StateChangeEvent,
+ };
+ currentTask.setTaskStateAndPublishUpdate(
+ 'input-required',
+ stateChange,
+ undefined,
+ undefined,
+ true,
+ );
+ } else {
+ logger.info(
+ `[CoderAgentExecutor] Task ${taskId}: Found ${completedTools.length} completed tool calls. Sending results back to LLM.`,
+ );
+
+ agentEvents = currentTask.sendCompletedToolsToLlm(
+ completedTools,
+ abortSignal,
+ );
+ }
+ } else {
+ logger.info(
+ `[CoderAgentExecutor] Task ${taskId}: No more tool calls to process. Ending agent turn.`,
+ );
+ agentTurnActive = false;
+ }
+ }
+ }
+
+ logger.info(
+ `[CoderAgentExecutor] Task ${taskId}: Agent turn finished, setting to input-required.`,
+ );
+ const stateChange: StateChange = {
+ kind: CoderAgentEvent.StateChangeEvent,
+ };
+ currentTask.setTaskStateAndPublishUpdate(
+ 'input-required',
+ stateChange,
+ undefined,
+ undefined,
+ true,
+ );
+ } catch (error) {
+ if (abortSignal.aborted) {
+ logger.warn(
+ `[CoderAgentExecutor] Task ${taskId} execution aborted.`,
+ );
+ currentTask.cancelPendingTools('Execution aborted');
+ if (
+ currentTask.taskState !== 'canceled' &&
+ currentTask.taskState !== 'failed'
+ ) {
+ currentTask.setTaskStateAndPublishUpdate(
+ 'input-required',
+ { kind: CoderAgentEvent.StateChangeEvent },
+ 'Execution aborted by client.',
+ undefined,
+ true,
+ );
+ }
+ } else {
+ const errorMessage =
+ error instanceof Error ? error.message : 'Agent execution error';
+ logger.error(
+ `[CoderAgentExecutor] Error executing agent for task ${taskId}:`,
+ error,
+ );
+ currentTask.cancelPendingTools(errorMessage);
+ if (currentTask.taskState !== 'failed') {
+ const stateChange: StateChange = {
+ kind: CoderAgentEvent.StateChangeEvent,
+ };
+ currentTask.setTaskStateAndPublishUpdate(
+ 'failed',
+ stateChange,
+ errorMessage,
+ undefined,
+ true,
+ );
+ }
+ }
+ } finally {
+ if (isPrimaryExecution) {
+ const controllers = this.activeAbortControllers.get(taskId);
+ if (controllers) {
+ controllers.delete(abortController);
+ if (controllers.size === 0) {
+ this.activeAbortControllers.delete(taskId);
+ }
+ }
+ if (this.executingTasks.get(taskId) === abortController) {
+ this.executingTasks.delete(taskId);
+ }
+ if (
+ ['canceled', 'failed', 'completed'].includes(
+ currentTask.taskState,
+ )
+ ) {
+ this.cleanupAndEvictTask(taskId, wrapper);
+ }
+ logger.info(
+ `[CoderAgentExecutor] Saving final state for task ${taskId}.`,
+ );
+ try {
+ await this.taskStore?.save(wrapper.toSDKTask());
+ logger.info(`[CoderAgentExecutor] Task ${taskId} state saved.`);
+ } catch (saveError) {
+ logger.error(
+ `[CoderAgentExecutor] Failed to save task ${taskId} state in finally block:`,
+ saveError,
+ );
+ }
+ }
+ }
+ } catch (error) {
+ logger.error(
+ `[CoderAgentExecutor] Failed to execute task ${taskId}:`,
+ error,
+ );
+ void pushTaskStateFailed(error, eventBus, taskId, contextId);
+ throw error;
+ }
+ }).catch((error) => {
+ logger.error(
+ `[CoderAgentExecutor] Failed to initialize isolated environment for task ${taskId}:`,
+ error,
+ );
+ if (eventBus) {
+ void pushTaskStateFailed(error, eventBus, taskId, contextId);
+ }
+ throw error;
+ });
+ }
+}
diff --git a/packages/a2a-server/src/agent/race-condition.test.ts b/packages/a2a-server/src/agent/race-condition.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3906c43a68a5ab79a6987799a0eedf8646f4946a
--- /dev/null
+++ b/packages/a2a-server/src/agent/race-condition.test.ts
@@ -0,0 +1,173 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
+import { Task } from './task.js';
+import {
+ MessageBusType,
+ CoreToolCallStatus,
+ type Config,
+ type MessageBus,
+} from '@google/gemini-cli-core';
+import { createMockConfig } from '../utils/testing_utils.js';
+import type { RequestContext } from '@a2a-js/sdk/server';
+
+describe('Task Race Condition', () => {
+ let mockConfig: Config;
+ let messageBus: MessageBus;
+
+ beforeEach(() => {
+ messageBus = {
+ subscribe: vi.fn(),
+ unsubscribe: vi.fn(),
+ publish: vi.fn(),
+ } as unknown as MessageBus;
+ mockConfig = createMockConfig({
+ messageBus,
+ }) as Config;
+ });
+
+ it('should not hang when multiple tool confirmations are processed while waiting', async () => {
+ // @ts-expect-error - private constructor
+ const task = new Task('task-id', 'context-id', mockConfig);
+
+ // 1. Register two tools as scheduled
+ task['_registerToolCall']('tool-1', 'scheduled');
+ task['_registerToolCall']('tool-2', 'scheduled');
+
+ // 2. Both transition to awaiting_approval
+ const updateHandler = (messageBus.subscribe as Mock).mock.calls.find(
+ (c: unknown[]) => c[0] === MessageBusType.TOOL_CALLS_UPDATE,
+ )?.[1];
+
+ updateHandler({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ schedulerId: 'task-id',
+ toolCalls: [
+ {
+ request: { callId: 'tool-1', name: 't1' },
+ status: CoreToolCallStatus.AwaitingApproval,
+ correlationId: 'corr-1',
+ confirmationDetails: { type: 'info' },
+ },
+ {
+ request: { callId: 'tool-2', name: 't2' },
+ status: CoreToolCallStatus.AwaitingApproval,
+ correlationId: 'corr-2',
+ confirmationDetails: { type: 'info' },
+ },
+ ],
+ });
+
+ // 3. Confirm Tool 1. This makes isAwaitingApprovalOnly() return false.
+ for await (const _ of task.acceptUserMessage(
+ {
+ userMessage: {
+ parts: [
+ {
+ kind: 'data',
+ data: { callId: 'tool-1', outcome: 'proceed_once' },
+ },
+ ],
+ },
+ } as unknown as RequestContext,
+ new AbortController().signal,
+ )) {
+ // consume generator
+ }
+
+ // 4. Start waiting. This should now block because Tool 1 is confirmed (so we are waiting for its execution).
+ const waitPromise = task.waitForPendingTools();
+
+ // 5. Confirm Tool 2 while waiting.
+ for await (const _ of task.acceptUserMessage(
+ {
+ userMessage: {
+ parts: [
+ {
+ kind: 'data',
+ data: { callId: 'tool-2', outcome: 'proceed_once' },
+ },
+ ],
+ },
+ } as unknown as RequestContext,
+ new AbortController().signal,
+ )) {
+ // consume generator
+ }
+
+ // 6. Both tools complete successfully
+ updateHandler({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ schedulerId: 'task-id',
+ toolCalls: [
+ {
+ request: { callId: 'tool-1', name: 't1' },
+ status: CoreToolCallStatus.Success,
+ response: { responseParts: [] },
+ },
+ {
+ request: { callId: 'tool-2', name: 't2' },
+ status: CoreToolCallStatus.Success,
+ response: { responseParts: [] },
+ },
+ ],
+ });
+
+ // 7. Verify that the original waitPromise resolves.
+ await expect(waitPromise).resolves.toBeUndefined();
+ });
+
+ it('should reject waitForPendingTools when tools are cancelled', async () => {
+ // @ts-expect-error - private constructor
+ const task = new Task('task-id', 'context-id', mockConfig);
+
+ // 1. Register a tool
+ task['_registerToolCall']('tool-1', 'scheduled');
+
+ // 2. Start waiting
+ const waitPromise = task.waitForPendingTools();
+
+ // 3. Cancel pending tools
+ task.cancelPendingTools('User requested cancellation');
+
+ // 4. Verify waitPromise rejects with the reason
+ await expect(waitPromise).rejects.toThrow('User requested cancellation');
+ });
+
+ it('should handle concurrent tool scheduling correctly', async () => {
+ // @ts-expect-error - private constructor
+ const task = new Task('task-id', 'context-id', mockConfig);
+
+ // 1. Register a tool and start waiting
+ task['_registerToolCall']('tool-1', 'scheduled');
+ const waitPromise = task.waitForPendingTools();
+
+ // 2. Schedule another tool concurrently (e.g. from a secondary user message)
+ // This should NOT resolve the current waitPromise until both are done
+ await task.scheduleToolCalls(
+ [{ callId: 'tool-2', name: 't2', args: {} }],
+ new AbortController().signal,
+ );
+
+ expect(task['pendingToolCalls'].size).toBe(2);
+
+ // 3. Resolve tool 1
+ task['_resolveToolCall']('tool-1');
+
+ // 4. Verify waitPromise is still pending
+ let resolved = false;
+ waitPromise.then(() => (resolved = true));
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(resolved).toBe(false);
+
+ // 5. Resolve tool 2
+ task['_resolveToolCall']('tool-2');
+
+ // 6. Now it should resolve
+ await expect(waitPromise).resolves.toBeUndefined();
+ });
+});
diff --git a/packages/a2a-server/src/agent/task-event-driven.test.ts b/packages/a2a-server/src/agent/task-event-driven.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a67a2bee139228c6eaf69071b93b42c377dfbc19
--- /dev/null
+++ b/packages/a2a-server/src/agent/task-event-driven.test.ts
@@ -0,0 +1,762 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
+import { Task } from './task.js';
+import {
+ type Config,
+ MessageBusType,
+ ToolConfirmationOutcome,
+ ApprovalMode,
+ Scheduler,
+ type MessageBus,
+ type ToolLiveOutput,
+} from '@google/gemini-cli-core';
+import { createMockConfig } from '../utils/testing_utils.js';
+import type { ExecutionEventBus } from '@a2a-js/sdk/server';
+
+describe('Task Event-Driven Scheduler', () => {
+ let mockConfig: Config;
+ let mockEventBus: ExecutionEventBus;
+ let messageBus: MessageBus;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockConfig = createMockConfig({
+ isEventDrivenSchedulerEnabled: () => true,
+ }) as Config;
+ messageBus = mockConfig.messageBus;
+ mockEventBus = {
+ publish: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ once: vi.fn(),
+ removeAllListeners: vi.fn(),
+ finished: vi.fn(),
+ };
+ });
+
+ it('should instantiate Scheduler when enabled', () => {
+ // @ts-expect-error - Calling private constructor
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
+ expect(task.scheduler).toBeInstanceOf(Scheduler);
+ });
+
+ it('should subscribe to TOOL_CALLS_UPDATE and map status changes', async () => {
+ // @ts-expect-error - Calling private constructor
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
+
+ const toolCall = {
+ request: { callId: '1', name: 'ls', args: {} },
+ status: 'executing',
+ };
+
+ // Simulate MessageBus event
+ // Simulate MessageBus event
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
+ )?.[1];
+
+ if (!handler) {
+ throw new Error('TOOL_CALLS_UPDATE handler not found');
+ }
+
+ handler({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [toolCall],
+ schedulerId: 'task-id',
+ });
+
+ expect(mockEventBus.publish).toHaveBeenCalledWith(
+ expect.objectContaining({
+ status: expect.objectContaining({
+ state: 'submitted', // initial task state
+ }),
+ metadata: expect.objectContaining({
+ coderAgent: expect.objectContaining({
+ kind: 'tool-call-update',
+ }),
+ }),
+ }),
+ );
+ });
+
+ it('should handle tool confirmations by publishing to MessageBus', async () => {
+ // @ts-expect-error - Calling private constructor
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
+
+ const toolCall = {
+ request: { callId: '1', name: 'ls', args: {} },
+ status: 'awaiting_approval',
+ correlationId: 'corr-1',
+ confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
+ };
+
+ // Simulate MessageBus event to stash the correlationId
+ // Simulate MessageBus event
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
+ )?.[1];
+
+ if (!handler) {
+ throw new Error('TOOL_CALLS_UPDATE handler not found');
+ }
+
+ handler({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [toolCall],
+ schedulerId: 'task-id',
+ });
+
+ // Simulate A2A client confirmation
+ const part = {
+ kind: 'data',
+ data: {
+ callId: '1',
+ outcome: 'proceed_once',
+ },
+ };
+
+ const handled = await (
+ task as unknown as {
+ _handleToolConfirmationPart: (part: unknown) => Promise;
+ }
+ )._handleToolConfirmationPart(part);
+ expect(handled).toBe(true);
+
+ expect(messageBus.publish).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
+ correlationId: 'corr-1',
+ confirmed: true,
+ outcome: ToolConfirmationOutcome.ProceedOnce,
+ }),
+ );
+ });
+
+ it('should handle Rejection (Cancel) and Modification (ModifyWithEditor)', async () => {
+ // @ts-expect-error - Calling private constructor
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
+
+ const toolCall = {
+ request: { callId: '1', name: 'ls', args: {} },
+ status: 'awaiting_approval',
+ correlationId: 'corr-1',
+ confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
+ };
+
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
+ )?.[1];
+ handler({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [toolCall],
+ schedulerId: 'task-id',
+ });
+
+ // Simulate Rejection (Cancel)
+ const handled = await (
+ task as unknown as {
+ _handleToolConfirmationPart: (part: unknown) => Promise;
+ }
+ )._handleToolConfirmationPart({
+ kind: 'data',
+ data: { callId: '1', outcome: 'cancel' },
+ });
+ expect(handled).toBe(true);
+ expect(messageBus.publish).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
+ correlationId: 'corr-1',
+ confirmed: false,
+ }),
+ );
+
+ const toolCall2 = {
+ request: { callId: '2', name: 'ls', args: {} },
+ status: 'awaiting_approval',
+ correlationId: 'corr-2',
+ confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
+ };
+ handler({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [toolCall2],
+ schedulerId: 'task-id',
+ });
+
+ // Simulate ModifyWithEditor
+ const handled2 = await (
+ task as unknown as {
+ _handleToolConfirmationPart: (part: unknown) => Promise;
+ }
+ )._handleToolConfirmationPart({
+ kind: 'data',
+ data: { callId: '2', outcome: 'modify_with_editor' },
+ });
+ expect(handled2).toBe(true);
+ expect(messageBus.publish).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
+ correlationId: 'corr-2',
+ confirmed: false,
+ outcome: ToolConfirmationOutcome.ModifyWithEditor,
+ payload: undefined,
+ }),
+ );
+ });
+
+ it('should handle MCP Server tool operations correctly', async () => {
+ // @ts-expect-error - Calling private constructor
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
+
+ const toolCall = {
+ request: { callId: '1', name: 'call_mcp_tool', args: {} },
+ status: 'awaiting_approval',
+ correlationId: 'corr-mcp-1',
+ confirmationDetails: {
+ type: 'mcp',
+ title: 'MCP Server Operation',
+ prompt: 'test_mcp',
+ },
+ };
+
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
+ )?.[1];
+ handler({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [toolCall],
+ schedulerId: 'task-id',
+ });
+
+ // Simulate ProceedOnce for MCP
+ const handled = await (
+ task as unknown as {
+ _handleToolConfirmationPart: (part: unknown) => Promise;
+ }
+ )._handleToolConfirmationPart({
+ kind: 'data',
+ data: { callId: '1', outcome: 'proceed_once' },
+ });
+ expect(handled).toBe(true);
+ expect(messageBus.publish).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
+ correlationId: 'corr-mcp-1',
+ confirmed: true,
+ outcome: ToolConfirmationOutcome.ProceedOnce,
+ }),
+ );
+ });
+
+ it('should handle MCP Server tool ProceedAlwaysServer outcome', async () => {
+ // @ts-expect-error - Calling private constructor
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
+
+ const toolCall = {
+ request: { callId: '1', name: 'call_mcp_tool', args: {} },
+ status: 'awaiting_approval',
+ correlationId: 'corr-mcp-2',
+ confirmationDetails: {
+ type: 'mcp',
+ title: 'MCP Server Operation',
+ prompt: 'test_mcp',
+ },
+ };
+
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
+ )?.[1];
+ handler({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [toolCall],
+ schedulerId: 'task-id',
+ });
+
+ const handled = await (
+ task as unknown as {
+ _handleToolConfirmationPart: (part: unknown) => Promise;
+ }
+ )._handleToolConfirmationPart({
+ kind: 'data',
+ data: { callId: '1', outcome: 'proceed_always_server' },
+ });
+ expect(handled).toBe(true);
+ expect(messageBus.publish).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
+ correlationId: 'corr-mcp-2',
+ confirmed: true,
+ outcome: ToolConfirmationOutcome.ProceedAlwaysServer,
+ }),
+ );
+ });
+
+ it('should handle MCP Server tool ProceedAlwaysTool outcome', async () => {
+ // @ts-expect-error - Calling private constructor
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
+
+ const toolCall = {
+ request: { callId: '1', name: 'call_mcp_tool', args: {} },
+ status: 'awaiting_approval',
+ correlationId: 'corr-mcp-3',
+ confirmationDetails: {
+ type: 'mcp',
+ title: 'MCP Server Operation',
+ prompt: 'test_mcp',
+ },
+ };
+
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
+ )?.[1];
+ handler({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [toolCall],
+ schedulerId: 'task-id',
+ });
+
+ const handled = await (
+ task as unknown as {
+ _handleToolConfirmationPart: (part: unknown) => Promise;
+ }
+ )._handleToolConfirmationPart({
+ kind: 'data',
+ data: { callId: '1', outcome: 'proceed_always_tool' },
+ });
+ expect(handled).toBe(true);
+ expect(messageBus.publish).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
+ correlationId: 'corr-mcp-3',
+ confirmed: true,
+ outcome: ToolConfirmationOutcome.ProceedAlwaysTool,
+ }),
+ );
+ });
+
+ it('should handle MCP Server tool ProceedAlwaysAndSave outcome', async () => {
+ // @ts-expect-error - Calling private constructor
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
+
+ const toolCall = {
+ request: { callId: '1', name: 'call_mcp_tool', args: {} },
+ status: 'awaiting_approval',
+ correlationId: 'corr-mcp-4',
+ confirmationDetails: {
+ type: 'mcp',
+ title: 'MCP Server Operation',
+ prompt: 'test_mcp',
+ },
+ };
+
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
+ )?.[1];
+ handler({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [toolCall],
+ schedulerId: 'task-id',
+ });
+
+ const handled = await (
+ task as unknown as {
+ _handleToolConfirmationPart: (part: unknown) => Promise;
+ }
+ )._handleToolConfirmationPart({
+ kind: 'data',
+ data: { callId: '1', outcome: 'proceed_always_and_save' },
+ });
+ expect(handled).toBe(true);
+ expect(messageBus.publish).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
+ correlationId: 'corr-mcp-4',
+ confirmed: true,
+ outcome: ToolConfirmationOutcome.ProceedAlwaysAndSave,
+ }),
+ );
+ });
+
+ it('should execute without confirmation in YOLO mode and not transition to input-required', async () => {
+ // Enable YOLO mode
+ const yoloConfig = createMockConfig({
+ isEventDrivenSchedulerEnabled: () => true,
+ getApprovalMode: () => ApprovalMode.YOLO,
+ }) as Config;
+ const yoloMessageBus = yoloConfig.messageBus;
+
+ // @ts-expect-error - Calling private constructor
+ const task = new Task('task-id', 'context-id', yoloConfig, mockEventBus);
+ task.setTaskStateAndPublishUpdate = vi.fn();
+
+ const toolCall = {
+ request: { callId: '1', name: 'ls', args: {} },
+ status: 'awaiting_approval',
+ correlationId: 'corr-1',
+ confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
+ };
+
+ const handler = (yoloMessageBus.subscribe as Mock).mock.calls.find(
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
+ )?.[1];
+ handler({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [toolCall],
+ schedulerId: 'task-id',
+ });
+
+ // Should NOT auto-publish ProceedOnce anymore, because PolicyEngine handles it directly
+ expect(yoloMessageBus.publish).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
+ }),
+ );
+
+ // Should NOT transition to input-required since it was auto-approved
+ expect(task.setTaskStateAndPublishUpdate).not.toHaveBeenCalledWith(
+ 'input-required',
+ expect.anything(),
+ undefined,
+ undefined,
+ true,
+ );
+ });
+
+ it('should handle output updates via the message bus', async () => {
+ // @ts-expect-error - Calling private constructor
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
+
+ const toolCall = {
+ request: { callId: '1', name: 'ls', args: {} },
+ status: 'executing',
+ liveOutput: 'chunk1',
+ };
+
+ // Simulate MessageBus event
+ // Simulate MessageBus event
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
+ )?.[1];
+
+ if (!handler) {
+ throw new Error('TOOL_CALLS_UPDATE handler not found');
+ }
+
+ handler({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [toolCall],
+ schedulerId: 'task-id',
+ });
+
+ // Should publish artifact update for output
+ expect(mockEventBus.publish).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kind: 'artifact-update',
+ artifact: expect.objectContaining({
+ artifactId: 'tool-1-output',
+ parts: [{ kind: 'text', text: 'chunk1' }],
+ }),
+ }),
+ );
+ });
+
+ it('should complete artifact creation without hanging', async () => {
+ // @ts-expect-error - Calling private constructor
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
+
+ const toolCallId = 'create-file-123';
+ task['_registerToolCall'](toolCallId, 'executing');
+
+ const toolCall = {
+ request: {
+ callId: toolCallId,
+ name: 'writeFile',
+ args: { path: 'test.sh' },
+ },
+ status: 'success',
+ result: { ok: true },
+ };
+
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
+ )?.[1];
+ handler({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [toolCall],
+ schedulerId: 'task-id',
+ });
+
+ // The tool should be complete and registered appropriately, eventually
+ // triggering the toolCompletionPromise resolution when all clear.
+ const internalTask = task as unknown as {
+ completedToolCalls: unknown[];
+ pendingToolCalls: Map;
+ };
+ expect(internalTask.completedToolCalls.length).toBe(1);
+ expect(internalTask.pendingToolCalls.size).toBe(0);
+ });
+
+ it('should preserve messageId across multiple text chunks to prevent UI duplication', async () => {
+ // @ts-expect-error - Calling private constructor
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
+
+ // Initialize the ID for the first turn (happens internally upon LLM stream)
+ task.currentAgentMessageId = 'test-id-123';
+
+ // Simulate sending multiple text chunks
+ task._sendTextContent('chunk 1');
+ task._sendTextContent('chunk 2');
+
+ // Both text contents should have been published with the same messageId
+ const textCalls = (mockEventBus.publish as Mock).mock.calls.filter(
+ (call) => call[0].status?.message?.kind === 'message',
+ );
+ expect(textCalls.length).toBe(2);
+ expect(textCalls[0][0].status.message.messageId).toBe('test-id-123');
+ expect(textCalls[1][0].status.message.messageId).toBe('test-id-123');
+
+ // Simulate starting a new turn by calling getAndClearCompletedTools
+ // (which precedes sendCompletedToolsToLlm where a new ID is minted)
+ task.getAndClearCompletedTools();
+
+ // sendCompletedToolsToLlm internally rolls the ID forward.
+ // Simulate what sendCompletedToolsToLlm does:
+ const internalTask = task as unknown as {
+ setTaskStateAndPublishUpdate: (state: string, change: unknown) => void;
+ };
+ internalTask.setTaskStateAndPublishUpdate('working', {});
+
+ // Simulate what sendCompletedToolsToLlm does: generate a new UUID for the next turn
+ task.currentAgentMessageId = 'test-id-456';
+
+ task._sendTextContent('chunk 3');
+
+ const secondTurnCalls = (mockEventBus.publish as Mock).mock.calls.filter(
+ (call) => call[0].status?.message?.messageId === 'test-id-456',
+ );
+ expect(secondTurnCalls.length).toBe(1);
+ expect(secondTurnCalls[0][0].status.message.parts[0].text).toBe('chunk 3');
+ });
+
+ it('should handle parallel tool calls correctly', async () => {
+ // @ts-expect-error - Calling private constructor
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
+
+ const toolCall1 = {
+ request: { callId: '1', name: 'ls', args: {} },
+ status: 'awaiting_approval',
+ correlationId: 'corr-1',
+ confirmationDetails: { type: 'info', title: 'test 1', prompt: 'test 1' },
+ };
+
+ const toolCall2 = {
+ request: { callId: '2', name: 'pwd', args: {} },
+ status: 'awaiting_approval',
+ correlationId: 'corr-2',
+ confirmationDetails: { type: 'info', title: 'test 2', prompt: 'test 2' },
+ };
+
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
+ )?.[1];
+
+ // Publish update for both tool calls simultaneously
+ handler({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [toolCall1, toolCall2],
+ schedulerId: 'task-id',
+ });
+
+ // Confirm first tool call
+ const handled1 = await (
+ task as unknown as {
+ _handleToolConfirmationPart: (part: unknown) => Promise;
+ }
+ )._handleToolConfirmationPart({
+ kind: 'data',
+ data: { callId: '1', outcome: 'proceed_once' },
+ });
+ expect(handled1).toBe(true);
+ expect(messageBus.publish).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
+ correlationId: 'corr-1',
+ confirmed: true,
+ }),
+ );
+
+ // Confirm second tool call
+ const handled2 = await (
+ task as unknown as {
+ _handleToolConfirmationPart: (part: unknown) => Promise;
+ }
+ )._handleToolConfirmationPart({
+ kind: 'data',
+ data: { callId: '2', outcome: 'cancel' },
+ });
+ expect(handled2).toBe(true);
+ expect(messageBus.publish).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
+ correlationId: 'corr-2',
+ confirmed: false,
+ }),
+ );
+ });
+
+ it('should handle multi-turn tool resolution correctly', async () => {
+ // @ts-expect-error - Calling private constructor
+ const task = new Task('task-id', 'context-id', mockConfig);
+
+ task['_registerToolCall']('1', 'scheduled');
+ task['_registerToolCall']('2', 'scheduled');
+
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
+ )?.[1];
+
+ // Turn 1: Resolve tool 1
+ handler({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [
+ {
+ request: { callId: '1', name: 't1' },
+ status: 'success',
+ response: { responseParts: [] },
+ },
+ ],
+ schedulerId: 'task-id',
+ });
+
+ expect(task['pendingToolCalls'].size).toBe(1);
+ expect(task['pendingToolCalls'].has('2')).toBe(true);
+
+ // Turn 2: Resolve tool 2
+ handler({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [
+ {
+ request: { callId: '2', name: 't2' },
+ status: 'success',
+ response: { responseParts: [] },
+ },
+ ],
+ schedulerId: 'task-id',
+ });
+
+ expect(task['pendingToolCalls'].size).toBe(0);
+ });
+
+ it('should handle subagent progress events from the scheduler', async () => {
+ // @ts-expect-error - Calling private constructor
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
+
+ // Trigger _schedulerOutputUpdate with subagent progress
+ task['_schedulerOutputUpdate']('tool-1', {
+ isSubagentProgress: true,
+ agentName: 'researcher',
+ recentActivity: [],
+ } as ToolLiveOutput);
+
+ expect(mockEventBus.publish).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kind: 'artifact-update',
+ artifact: expect.objectContaining({
+ parts: [
+ expect.objectContaining({
+ text: expect.stringContaining('researcher'),
+ }),
+ ],
+ }),
+ }),
+ );
+ });
+
+ it('should wait for executing tools before transitioning to input-required state', async () => {
+ // @ts-expect-error - Calling private constructor
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
+
+ task.setTaskStateAndPublishUpdate = vi.fn();
+
+ // Register tool 1 as executing
+ task['_registerToolCall']('1', 'executing');
+
+ const toolCall1 = {
+ request: { callId: '1', name: 'ls', args: {} },
+ status: 'executing',
+ };
+
+ const toolCall2 = {
+ request: { callId: '2', name: 'pwd', args: {} },
+ status: 'awaiting_approval',
+ correlationId: 'corr-2',
+ confirmationDetails: { type: 'info', title: 'test 2', prompt: 'test 2' },
+ };
+
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
+ )?.[1];
+
+ handler({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [toolCall1, toolCall2],
+ schedulerId: 'task-id',
+ });
+
+ // Should NOT transition to input-required yet
+ expect(task.setTaskStateAndPublishUpdate).not.toHaveBeenCalledWith(
+ 'input-required',
+ expect.anything(),
+ undefined,
+ undefined,
+ true,
+ );
+
+ // Complete tool 1
+ const toolCall1Complete = {
+ ...toolCall1,
+ status: 'success',
+ result: { ok: true },
+ };
+
+ handler({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [toolCall1Complete, toolCall2],
+ schedulerId: 'task-id',
+ });
+
+ // Now it should transition
+ expect(task.setTaskStateAndPublishUpdate).toHaveBeenCalledWith(
+ 'input-required',
+ expect.anything(),
+ undefined,
+ undefined,
+ true,
+ );
+ });
+
+ it('should ignore confirmations for unknown tool calls', async () => {
+ // @ts-expect-error - Calling private constructor
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
+
+ const handled = await (
+ task as unknown as {
+ _handleToolConfirmationPart: (part: unknown) => Promise;
+ }
+ )._handleToolConfirmationPart({
+ kind: 'data',
+ data: { callId: 'unknown-id', outcome: 'proceed_once' },
+ });
+
+ // Should return false for unhandled tool call
+ expect(handled).toBe(false);
+
+ // Should not publish anything to the message bus
+ expect(messageBus.publish).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/a2a-server/src/agent/task.test.ts b/packages/a2a-server/src/agent/task.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8c1772cac227ba3fbb805f7427107a7154ff335f
--- /dev/null
+++ b/packages/a2a-server/src/agent/task.test.ts
@@ -0,0 +1,916 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
+import { Task } from './task.js';
+import {
+ GeminiEventType,
+ type Config,
+ type ToolCallRequestInfo,
+ type GitService,
+ type CompletedToolCall,
+ type ToolCall,
+ type ToolCallsUpdateMessage,
+ MessageBusType,
+} from '@google/gemini-cli-core';
+import { createMockConfig } from '../utils/testing_utils.js';
+import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
+import { CoderAgentEvent } from '../types.js';
+
+const mockProcessRestorableToolCalls = vi.hoisted(() => vi.fn());
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const original =
+ await importOriginal();
+ return {
+ ...original,
+ processRestorableToolCalls: mockProcessRestorableToolCalls,
+ };
+});
+
+describe('Task', () => {
+ it('scheduleToolCalls should not modify the input requests array', async () => {
+ const mockConfig = createMockConfig();
+
+ const mockEventBus: ExecutionEventBus = {
+ publish: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ once: vi.fn(),
+ removeAllListeners: vi.fn(),
+ finished: vi.fn(),
+ };
+
+ // The Task constructor is private. We'll bypass it for this unit test.
+ // @ts-expect-error - Calling private constructor for test purposes.
+ const task = new Task(
+ 'task-id',
+ 'context-id',
+ mockConfig as Config,
+ mockEventBus,
+ );
+
+ task['setTaskStateAndPublishUpdate'] = vi.fn();
+ task['getProposedContent'] = vi.fn().mockResolvedValue('new content');
+
+ const requests: ToolCallRequestInfo[] = [
+ {
+ callId: '1',
+ name: 'replace',
+ args: {
+ file_path: 'test.txt',
+ old_string: 'old',
+ new_string: 'new',
+ },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-1',
+ },
+ ];
+
+ const originalRequests = JSON.parse(JSON.stringify(requests));
+ const abortController = new AbortController();
+
+ await task.scheduleToolCalls(requests, abortController.signal);
+
+ expect(requests).toEqual(originalRequests);
+ });
+
+ describe('scheduleToolCalls', () => {
+ const mockConfig = createMockConfig();
+ const mockEventBus: ExecutionEventBus = {
+ publish: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ once: vi.fn(),
+ removeAllListeners: vi.fn(),
+ finished: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should not create a checkpoint if no restorable tools are called', async () => {
+ // @ts-expect-error - Calling private constructor for test purposes.
+ const task = new Task(
+ 'task-id',
+ 'context-id',
+ mockConfig as Config,
+ mockEventBus,
+ );
+ const requests: ToolCallRequestInfo[] = [
+ {
+ callId: '1',
+ name: 'run_shell_command',
+ args: { command: 'ls' },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-1',
+ },
+ ];
+ const abortController = new AbortController();
+ await task.scheduleToolCalls(requests, abortController.signal);
+ expect(mockProcessRestorableToolCalls).not.toHaveBeenCalled();
+ });
+
+ it('should create a checkpoint if a restorable tool is called', async () => {
+ const mockConfig = createMockConfig({
+ getCheckpointingEnabled: () => true,
+ getGitService: () => Promise.resolve({} as GitService),
+ });
+ mockProcessRestorableToolCalls.mockResolvedValue({
+ checkpointsToWrite: new Map([['test.json', 'test content']]),
+ toolCallToCheckpointMap: new Map(),
+ errors: [],
+ });
+ // @ts-expect-error - Calling private constructor for test purposes.
+ const task = new Task(
+ 'task-id',
+ 'context-id',
+ mockConfig as Config,
+ mockEventBus,
+ );
+ const requests: ToolCallRequestInfo[] = [
+ {
+ callId: '1',
+ name: 'replace',
+ args: {
+ file_path: 'test.txt',
+ old_string: 'old',
+ new_string: 'new',
+ },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-1',
+ },
+ ];
+ const abortController = new AbortController();
+ await task.scheduleToolCalls(requests, abortController.signal);
+ expect(mockProcessRestorableToolCalls).toHaveBeenCalledOnce();
+ });
+
+ it('should process all restorable tools for checkpointing in a single batch', async () => {
+ const mockConfig = createMockConfig({
+ getCheckpointingEnabled: () => true,
+ getGitService: () => Promise.resolve({} as GitService),
+ });
+ mockProcessRestorableToolCalls.mockResolvedValue({
+ checkpointsToWrite: new Map([
+ ['test1.json', 'test content 1'],
+ ['test2.json', 'test content 2'],
+ ]),
+ toolCallToCheckpointMap: new Map([
+ ['1', 'test1'],
+ ['2', 'test2'],
+ ]),
+ errors: [],
+ });
+ // @ts-expect-error - Calling private constructor for test purposes.
+ const task = new Task(
+ 'task-id',
+ 'context-id',
+ mockConfig as Config,
+ mockEventBus,
+ );
+ const requests: ToolCallRequestInfo[] = [
+ {
+ callId: '1',
+ name: 'replace',
+ args: {
+ file_path: 'test.txt',
+ old_string: 'old',
+ new_string: 'new',
+ },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-1',
+ },
+ {
+ callId: '2',
+ name: 'write_file',
+ args: { file_path: 'test2.txt', content: 'new content' },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-2',
+ },
+ {
+ callId: '3',
+ name: 'not_restorable',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-3',
+ },
+ ];
+ const abortController = new AbortController();
+ await task.scheduleToolCalls(requests, abortController.signal);
+ expect(mockProcessRestorableToolCalls).toHaveBeenCalledExactlyOnceWith(
+ [
+ expect.objectContaining({ callId: '1' }),
+ expect.objectContaining({ callId: '2' }),
+ ],
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ });
+
+ describe('acceptAgentMessage', () => {
+ it('should set currentTraceId when event has traceId', async () => {
+ const mockConfig = createMockConfig();
+ const mockEventBus: ExecutionEventBus = {
+ publish: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ once: vi.fn(),
+ removeAllListeners: vi.fn(),
+ finished: vi.fn(),
+ };
+
+ // @ts-expect-error - Calling private constructor for test purposes.
+ const task = new Task(
+ 'task-id',
+ 'context-id',
+ mockConfig as Config,
+ mockEventBus,
+ );
+
+ const event = {
+ type: 'content',
+ value: 'test',
+ traceId: 'test-trace-id',
+ };
+
+ await task.acceptAgentMessage(event);
+
+ expect(mockEventBus.publish).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata: expect.objectContaining({
+ traceId: 'test-trace-id',
+ }),
+ }),
+ );
+ });
+
+ it('should handle Citation event and publish to event bus', async () => {
+ const mockConfig = createMockConfig();
+ const mockEventBus: ExecutionEventBus = {
+ publish: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ once: vi.fn(),
+ removeAllListeners: vi.fn(),
+ finished: vi.fn(),
+ };
+
+ // @ts-expect-error - Calling private constructor for test purposes.
+ const task = new Task(
+ 'task-id',
+ 'context-id',
+ mockConfig as Config,
+ mockEventBus,
+ );
+
+ const citationText = 'Source: example.com';
+ const citationEvent = {
+ type: GeminiEventType.Citation,
+ value: citationText,
+ };
+
+ await task.acceptAgentMessage(citationEvent);
+
+ expect(mockEventBus.publish).toHaveBeenCalledOnce();
+ const publishedEvent = (mockEventBus.publish as Mock).mock.calls[0][0];
+
+ expect(publishedEvent.kind).toBe('status-update');
+ expect(publishedEvent.taskId).toBe('task-id');
+ expect(publishedEvent.metadata.coderAgent.kind).toBe(
+ CoderAgentEvent.CitationEvent,
+ );
+ expect(publishedEvent.status.message).toBeDefined();
+ expect(publishedEvent.status.message.parts).toEqual([
+ {
+ kind: 'text',
+ text: citationText,
+ },
+ ]);
+ });
+
+ it('should capture usageMetadata on Finished event and include it in final status update', async () => {
+ const mockConfig = createMockConfig();
+ const mockEventBus: ExecutionEventBus = {
+ publish: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ once: vi.fn(),
+ removeAllListeners: vi.fn(),
+ finished: vi.fn(),
+ };
+
+ // @ts-expect-error - Calling private constructor for test purposes.
+ const task = new Task(
+ 'task-id',
+ 'context-id',
+ mockConfig as Config,
+ mockEventBus,
+ );
+
+ const finishedEvent = {
+ type: GeminiEventType.Finished,
+ value: {
+ reason: 'STOP',
+ usageMetadata: {
+ promptTokenCount: 100,
+ candidatesTokenCount: 50,
+ totalTokenCount: 150,
+ },
+ },
+ };
+
+ await task.acceptAgentMessage(finishedEvent);
+ expect(task.usageMetadata).toEqual({
+ promptTokenCount: 100,
+ candidatesTokenCount: 50,
+ totalTokenCount: 150,
+ });
+
+ task.setTaskStateAndPublishUpdate(
+ 'input-required',
+ { kind: CoderAgentEvent.StateChangeEvent },
+ undefined,
+ undefined,
+ true, // final
+ );
+
+ expect(mockEventBus.publish).toHaveBeenCalledWith(
+ expect.objectContaining({
+ final: true,
+ metadata: expect.objectContaining({
+ usageMetadata: {
+ promptTokenCount: 100,
+ candidatesTokenCount: 50,
+ totalTokenCount: 150,
+ },
+ }),
+ }),
+ );
+ });
+
+ it('should update modelInfo and reflect it in metadata and status updates', async () => {
+ const mockConfig = createMockConfig();
+ const mockEventBus: ExecutionEventBus = {
+ publish: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ once: vi.fn(),
+ removeAllListeners: vi.fn(),
+ finished: vi.fn(),
+ };
+
+ // @ts-expect-error - Calling private constructor for test purposes.
+ const task = new Task(
+ 'task-id',
+ 'context-id',
+ mockConfig as Config,
+ mockEventBus,
+ );
+
+ const modelInfoEvent = {
+ type: GeminiEventType.ModelInfo,
+ value: 'new-model-name',
+ };
+
+ await task.acceptAgentMessage(modelInfoEvent);
+
+ expect(task.modelInfo).toBe('new-model-name');
+
+ // Check getMetadata
+ const metadata = await task.getMetadata();
+ expect(metadata.model).toBe('new-model-name');
+
+ // Check status update
+ task.setTaskStateAndPublishUpdate(
+ 'working',
+ { kind: CoderAgentEvent.StateChangeEvent },
+ 'Working...',
+ );
+
+ expect(mockEventBus.publish).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata: expect.objectContaining({
+ model: 'new-model-name',
+ }),
+ }),
+ );
+ });
+
+ it.each([
+ { eventType: GeminiEventType.Retry, eventName: 'Retry' },
+ { eventType: GeminiEventType.InvalidStream, eventName: 'InvalidStream' },
+ ])(
+ 'should handle $eventName event without triggering error handling',
+ async ({ eventType }) => {
+ const mockConfig = createMockConfig();
+ const mockEventBus: ExecutionEventBus = {
+ publish: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ once: vi.fn(),
+ removeAllListeners: vi.fn(),
+ finished: vi.fn(),
+ };
+
+ // @ts-expect-error - Calling private constructor
+ const task = new Task(
+ 'task-id',
+ 'context-id',
+ mockConfig as Config,
+ mockEventBus,
+ );
+
+ const cancelPendingToolsSpy = vi.spyOn(task, 'cancelPendingTools');
+ const setTaskStateSpy = vi.spyOn(task, 'setTaskStateAndPublishUpdate');
+
+ const event = {
+ type: eventType,
+ };
+
+ await task.acceptAgentMessage(event);
+
+ expect(cancelPendingToolsSpy).not.toHaveBeenCalled();
+ expect(setTaskStateSpy).not.toHaveBeenCalled();
+ },
+ );
+ });
+
+ describe('currentPromptId and promptCount', () => {
+ it('should correctly initialize and update promptId and promptCount', async () => {
+ const mockConfig = createMockConfig();
+ mockConfig.getGeminiClient = vi.fn().mockReturnValue({
+ sendMessageStream: vi.fn().mockReturnValue((async function* () {})()),
+ });
+ mockConfig.getSessionId = () => 'test-session-id';
+
+ const mockEventBus: ExecutionEventBus = {
+ publish: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ once: vi.fn(),
+ removeAllListeners: vi.fn(),
+ finished: vi.fn(),
+ };
+
+ // @ts-expect-error - Calling private constructor
+ const task = new Task(
+ 'task-id',
+ 'context-id',
+ mockConfig as Config,
+ mockEventBus,
+ );
+
+ // Initial state
+ expect(task.currentPromptId).toBeUndefined();
+ expect(task.promptCount).toBe(0);
+
+ // First user message should set prompt_id
+ const userMessage1 = {
+ userMessage: {
+ parts: [{ kind: 'text', text: 'hello' }],
+ },
+ } as RequestContext;
+ const abortController1 = new AbortController();
+ for await (const _ of task.acceptUserMessage(
+ userMessage1,
+ abortController1.signal,
+ )) {
+ // no-op
+ }
+
+ const expectedPromptId1 = 'test-session-id########0';
+ expect(task.promptCount).toBe(1);
+ expect(task.currentPromptId).toBe(expectedPromptId1);
+
+ // A new user message should generate a new prompt_id
+ const userMessage2 = {
+ userMessage: {
+ parts: [{ kind: 'text', text: 'world' }],
+ },
+ } as RequestContext;
+ const abortController2 = new AbortController();
+ for await (const _ of task.acceptUserMessage(
+ userMessage2,
+ abortController2.signal,
+ )) {
+ // no-op
+ }
+
+ const expectedPromptId2 = 'test-session-id########1';
+ expect(task.promptCount).toBe(2);
+ expect(task.currentPromptId).toBe(expectedPromptId2);
+
+ // Subsequent tool call processing should use the same prompt_id
+ const completedTool = {
+ request: { callId: 'tool-1' },
+ response: { responseParts: [{ text: 'tool output' }] },
+ } as CompletedToolCall;
+ const abortController3 = new AbortController();
+ for await (const _ of task.sendCompletedToolsToLlm(
+ [completedTool],
+ abortController3.signal,
+ )) {
+ // no-op
+ }
+
+ expect(task.promptCount).toBe(2);
+ expect(task.currentPromptId).toBe(expectedPromptId2);
+ });
+ });
+
+ describe('Race Condition Fix', () => {
+ const mockConfig = createMockConfig();
+ const mockEventBus: ExecutionEventBus = {
+ publish: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ once: vi.fn(),
+ removeAllListeners: vi.fn(),
+ finished: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should NOT transition to input-required if a tool is still validating', async () => {
+ // @ts-expect-error - Calling private constructor
+ const task = new Task(
+ 'task-id',
+ 'context-id',
+ mockConfig as Config,
+ mockEventBus,
+ );
+
+ // Manually register two tool calls
+ task['_registerToolCall']('tool-1', 'awaiting_approval');
+ task['_registerToolCall']('tool-2', 'validating');
+
+ // Call checkInputRequiredState (private)
+ task['checkInputRequiredState']();
+
+ // Verify task state did NOT change to input-required
+ expect(task.taskState).not.toBe('input-required');
+ expect(mockEventBus.publish).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ status: expect.objectContaining({ state: 'input-required' }),
+ }),
+ );
+ });
+
+ it('should transition to input-required if all active tools are awaiting approval', async () => {
+ // @ts-expect-error - Calling private constructor
+ const task = new Task(
+ 'task-id',
+ 'context-id',
+ mockConfig as Config,
+ mockEventBus,
+ );
+
+ // Transition from submitted to working first to simulate normal flow
+ task.taskState = 'working';
+
+ // Manually register tool calls
+ task['_registerToolCall']('tool-1', 'awaiting_approval');
+
+ // Call checkInputRequiredState
+ task['checkInputRequiredState']();
+
+ // Verify task state changed to input-required
+ expect(task.taskState).toBe('input-required');
+ expect(mockEventBus.publish).toHaveBeenCalledWith(
+ expect.objectContaining({
+ status: expect.objectContaining({ state: 'input-required' }),
+ }),
+ );
+ });
+
+ it('handleEventDrivenToolCallsUpdate should ignore events for other schedulers', async () => {
+ // @ts-expect-error - Calling private constructor
+ const task = new Task(
+ 'task-id',
+ 'context-id',
+ mockConfig as Config,
+ mockEventBus,
+ );
+
+ const handleEventDrivenToolCallSpy = vi.spyOn(
+ task as unknown as {
+ handleEventDrivenToolCall: Task['handleEventDrivenToolCall'];
+ },
+ 'handleEventDrivenToolCall',
+ );
+
+ const otherEvent: ToolCallsUpdateMessage = {
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [
+ { request: { callId: '1' }, status: 'executing' } as ToolCall,
+ ],
+ schedulerId: 'other-task-id',
+ };
+
+ task['handleEventDrivenToolCallsUpdate'](otherEvent);
+
+ expect(handleEventDrivenToolCallSpy).not.toHaveBeenCalled();
+
+ const ownEvent: ToolCallsUpdateMessage = {
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [
+ { request: { callId: '1' }, status: 'executing' } as ToolCall,
+ ],
+ schedulerId: 'task-id',
+ };
+
+ task['handleEventDrivenToolCallsUpdate'](ownEvent);
+
+ expect(handleEventDrivenToolCallSpy).toHaveBeenCalled();
+ });
+
+ describe('Pending Tools state', () => {
+ it('should correctly report pending tools presence and count', () => {
+ const mockConfig = createMockConfig();
+ const mockEventBus: ExecutionEventBus = {
+ publish: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ once: vi.fn(),
+ removeAllListeners: vi.fn(),
+ finished: vi.fn(),
+ };
+
+ // @ts-expect-error - Calling private constructor
+ const task = new Task(
+ 'task-id',
+ 'context-id',
+ mockConfig as Config,
+ mockEventBus,
+ );
+
+ expect(task.hasPendingTools).toBe(false);
+ expect(task.pendingToolsCount).toBe(0);
+
+ task['_registerToolCall']('tool-1', 'scheduled');
+ expect(task.hasPendingTools).toBe(true);
+ expect(task.pendingToolsCount).toBe(1);
+ });
+ });
+ });
+
+ describe('Serialization and Mapping', () => {
+ it('should map internal "validating" status to "scheduled" for the client and include outcome', async () => {
+ const mockConfig = createMockConfig();
+ const mockEventBus: ExecutionEventBus = {
+ publish: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ once: vi.fn(),
+ removeAllListeners: vi.fn(),
+ finished: vi.fn(),
+ };
+
+ // @ts-expect-error - Calling private constructor
+ const task = new Task(
+ 'task-id',
+ 'context-id',
+ mockConfig as Config,
+ mockEventBus,
+ );
+
+ const mockToolCall = {
+ request: { callId: 'tool-1' },
+ status: 'validating',
+ outcome: 'accepted',
+ tool: { name: 'test-tool' },
+ };
+
+ const message = task['toolStatusMessage'](
+ mockToolCall as unknown as ToolCall,
+ 'task-id',
+ 'context-id',
+ );
+ const serialized = (
+ message.parts![0] as {
+ data: { status: string; outcome: string };
+ }
+ ).data;
+
+ expect(serialized.status).toBe('scheduled');
+ expect(serialized.outcome).toBe('accepted');
+ });
+
+ it('should correctly detect changes when status or outcome changes', async () => {
+ const mockConfig = createMockConfig();
+ const mockEventBus: ExecutionEventBus = {
+ publish: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ once: vi.fn(),
+ removeAllListeners: vi.fn(),
+ finished: vi.fn(),
+ };
+
+ // @ts-expect-error - Calling private constructor
+ const task = new Task(
+ 'task-id',
+ 'context-id',
+ mockConfig as Config,
+ mockEventBus,
+ );
+
+ const toolCall1 = {
+ request: { callId: 'tool-1' },
+ status: 'awaiting_approval',
+ };
+
+ // First update - should trigger change
+ const changed1 = task['handleEventDrivenToolCall'](
+ toolCall1 as unknown as ToolCall,
+ );
+ expect(changed1).toBe(true);
+
+ // Second update with same status - should NOT trigger change
+ const changed2 = task['handleEventDrivenToolCall'](
+ toolCall1 as unknown as ToolCall,
+ );
+ expect(changed2).toBe(false);
+
+ // Update with new outcome - SHOULD trigger change
+ const toolCall2 = {
+ request: { callId: 'tool-1' },
+ status: 'awaiting_approval',
+ outcome: 'accepted',
+ };
+ const changed3 = task['handleEventDrivenToolCall'](
+ toolCall2 as unknown as ToolCall,
+ );
+ expect(changed3).toBe(true);
+ });
+ });
+
+ describe('getProposedContent (CRLF Line Ending Normalization)', () => {
+ it('should successfully replace LF-based strings in CRLF-based files', async () => {
+ const fs = await import('node:fs');
+ const path = await import('node:path');
+ const os = await import('node:os');
+
+ const mockConfig = createMockConfig({
+ getTargetDir: () => os.tmpdir(),
+ validatePathAccess: () => null,
+ });
+ const mockEventBus: ExecutionEventBus = {
+ publish: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ once: vi.fn(),
+ removeAllListeners: vi.fn(),
+ finished: vi.fn(),
+ };
+
+ // @ts-expect-error - Calling private constructor
+ const task = new Task(
+ 'task-id',
+ 'context-id',
+ mockConfig as Config,
+ mockEventBus,
+ );
+
+ const tempFile = path.resolve(os.tmpdir(), 'crlf_test_file.txt');
+ const crlfContent = 'line1\r\nline2\r\nline3\r\n';
+ fs.writeFileSync(tempFile, crlfContent, 'utf8');
+
+ try {
+ const oldString = 'line2\n';
+ const newString = 'line2-optimized\n';
+
+ const result = await task['getProposedContent'](
+ tempFile,
+ oldString,
+ newString,
+ );
+
+ expect(result).toContain('line2-optimized');
+ expect(result).toContain('\r\n'); // It should preserve the original CRLF line endings
+ } finally {
+ if (fs.existsSync(tempFile)) {
+ fs.unlinkSync(tempFile);
+ }
+ }
+ });
+
+ it('should successfully replace CRLF-based strings in CRLF-based files by normalizing all to LF', async () => {
+ const fs = await import('node:fs');
+ const path = await import('node:path');
+ const os = await import('node:os');
+
+ const mockConfig = createMockConfig({
+ getTargetDir: () => os.tmpdir(),
+ validatePathAccess: () => null,
+ });
+ const mockEventBus: ExecutionEventBus = {
+ publish: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ once: vi.fn(),
+ removeAllListeners: vi.fn(),
+ finished: vi.fn(),
+ };
+
+ // @ts-expect-error - Calling private constructor
+ const task = new Task(
+ 'task-id',
+ 'context-id',
+ mockConfig as Config,
+ mockEventBus,
+ );
+
+ const tempFile = path.resolve(
+ os.tmpdir(),
+ 'crlf_test_file_crlf_inputs.txt',
+ );
+ const crlfContent = 'line1\r\nline2\r\nline3\r\n';
+ fs.writeFileSync(tempFile, crlfContent, 'utf8');
+
+ try {
+ const oldString = 'line2\r\n';
+ const newString = 'line2-optimized\r\n';
+
+ const result = await task['getProposedContent'](
+ tempFile,
+ oldString,
+ newString,
+ );
+
+ expect(result).toContain('line2-optimized');
+ expect(result).toContain('\r\n'); // It should preserve the original CRLF line endings
+ } finally {
+ if (fs.existsSync(tempFile)) {
+ fs.unlinkSync(tempFile);
+ }
+ }
+ });
+ });
+
+ describe('Stale Cancellation Error Cleanup', () => {
+ it('should clear any previous execution cancellation error on acceptUserMessage', async () => {
+ const mockConfig = createMockConfig();
+ mockConfig.getGeminiClient = vi.fn().mockReturnValue({
+ sendMessageStream: vi.fn().mockReturnValue((async function* () {})()),
+ });
+ mockConfig.getSessionId = () => 'test-session-id';
+
+ const mockEventBus: ExecutionEventBus = {
+ publish: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ once: vi.fn(),
+ removeAllListeners: vi.fn(),
+ finished: vi.fn(),
+ };
+
+ // @ts-expect-error - Calling private constructor
+ const task = new Task(
+ 'task-id',
+ 'context-id',
+ mockConfig as Config,
+ mockEventBus,
+ );
+
+ // Simulate cancellation from a previous aborted execution turn
+ task.cancelPendingTools('Execution aborted');
+
+ // Verify cancellationError is set and would throw on wait
+ expect(task['cancellationError']).toBeDefined();
+ await expect(task.waitForPendingTools()).rejects.toThrow(
+ 'Execution aborted',
+ );
+
+ // Call acceptUserMessage to start a new user turn
+ const userMessage = {
+ userMessage: {
+ parts: [{ kind: 'text', text: 'new prompt' }],
+ },
+ } as RequestContext;
+ const abortController = new AbortController();
+
+ // Drain the generator to let acceptUserMessage execute
+ for await (const _ of task.acceptUserMessage(
+ userMessage,
+ abortController.signal,
+ )) {
+ // no-op
+ }
+
+ // Verify cancellationError is cleared
+ expect(task['cancellationError']).toBeUndefined();
+
+ // Verify waiting for pending tools now succeeds/does not throw
+ await expect(task.waitForPendingTools()).resolves.toBeUndefined();
+ });
+ });
+});
diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0a1534f3cdda038db783fb93ccd5995a375a99c5
--- /dev/null
+++ b/packages/a2a-server/src/agent/task.ts
@@ -0,0 +1,1305 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ type AgentLoopContext,
+ Scheduler,
+ type GeminiClient,
+ GeminiEventType,
+ ToolConfirmationOutcome,
+ ApprovalMode,
+ CoreToolCallStatus,
+ getAllMCPServerStatuses,
+ MCPServerStatus,
+ isNodeError,
+ getErrorMessage,
+ parseAndFormatApiError,
+ safeLiteralReplace,
+ DEFAULT_GUI_EDITOR,
+ type AnyDeclarativeTool,
+ type ToolCall,
+ type ToolConfirmationPayload,
+ type CompletedToolCall,
+ type ToolCallRequestInfo,
+ type ServerGeminiErrorEvent,
+ type ServerGeminiStreamEvent,
+ type ToolCallConfirmationDetails,
+ type Config,
+ type UserTierId,
+ type ToolLiveOutput,
+ type AnsiLine,
+ type AnsiOutput,
+ type AnsiToken,
+ isSubagentProgress,
+ EDIT_TOOL_NAMES,
+ processRestorableToolCalls,
+ MessageBusType,
+ type ToolCallsUpdateMessage,
+} from '@google/gemini-cli-core';
+import {
+ type ExecutionEventBus,
+ type RequestContext,
+} from '@a2a-js/sdk/server';
+import type {
+ TaskStatusUpdateEvent,
+ TaskArtifactUpdateEvent,
+ TaskState,
+ Message,
+ Part,
+ Artifact,
+} from '@a2a-js/sdk';
+import { v4 as uuidv4 } from 'uuid';
+import { EventEmitter } from 'node:events';
+import { logger } from '../utils/logger.js';
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import {
+ CoderAgentEvent,
+ type CoderAgentMessage,
+ type StateChange,
+ type ToolCallUpdate,
+ type TextContent,
+ type TaskMetadata,
+ type Thought,
+ type ThoughtSummary,
+ type Citation,
+} from '../types.js';
+import type { PartUnion, Part as genAiPart } from '@google/genai';
+
+type UnionKeys = T extends T ? keyof T : never;
+
+export class Task {
+ id: string;
+ contextId: string;
+ scheduler: Scheduler;
+ config: Config;
+ geminiClient: GeminiClient;
+ pendingToolConfirmationDetails: Map;
+ pendingCorrelationIds: Map = new Map();
+ taskState: TaskState;
+ eventBus?: ExecutionEventBus;
+ completedToolCalls: CompletedToolCall[];
+ processedToolCallIds: Set = new Set();
+ skipFinalTrueAfterInlineEdit = false;
+ modelInfo?: string;
+ currentPromptId: string | undefined;
+ currentAgentMessageId = uuidv4();
+ promptCount = 0;
+ autoExecute: boolean;
+ usageMetadata?: {
+ promptTokenCount?: number;
+ candidatesTokenCount?: number;
+ totalTokenCount?: number;
+ cachedContentTokenCount?: number;
+ };
+ private get isYoloMatch(): boolean {
+ return (
+ this.autoExecute || this.config.getApprovalMode() === ApprovalMode.YOLO
+ );
+ }
+
+ // For tool waiting logic
+ private pendingToolCalls: Map = new Map(); //toolCallId --> status
+ private pendingOutcomes: Map =
+ new Map(); // toolCallId --> outcome
+ private toolsAlreadyConfirmed: Set = new Set();
+ private toolUpdateEmitter = new EventEmitter();
+ private cancellationError?: Error;
+
+ private constructor(
+ id: string,
+ contextId: string,
+ config: Config,
+ eventBus?: ExecutionEventBus,
+ autoExecute = false,
+ ) {
+ this.id = id;
+ this.contextId = contextId;
+ this.config = config;
+
+ this.scheduler = this.setupEventDrivenScheduler();
+
+ const loopContext: AgentLoopContext = this.config;
+ this.geminiClient = loopContext.geminiClient;
+ this.pendingToolConfirmationDetails = new Map();
+ this.taskState = 'submitted';
+ this.eventBus = eventBus;
+ this.completedToolCalls = [];
+ this.autoExecute = autoExecute;
+ this.config.setFallbackModelHandler(
+ // For a2a-server, we want to automatically switch to the fallback model
+ // for future requests without retrying the current one.
+ async (failedModel, fallbackModel) => {
+ this.config.activateFallbackMode(fallbackModel, failedModel);
+ return 'stop';
+ },
+ );
+ }
+
+ get hasPendingTools(): boolean {
+ return this.pendingToolCalls.size > 0;
+ }
+
+ get pendingToolsCount(): number {
+ return this.pendingToolCalls.size;
+ }
+
+ static async create(
+ id: string,
+ contextId: string,
+ config: Config,
+ eventBus?: ExecutionEventBus,
+ autoExecute?: boolean,
+ ): Promise {
+ return new Task(id, contextId, config, eventBus, autoExecute);
+ }
+
+ // Note: `getAllMCPServerStatuses` retrieves the status of all MCP servers for the entire
+ // process. This is not scoped to the individual task but reflects the global connection
+ // state managed within the @gemini-cli/core module.
+ async getMetadata(): Promise {
+ const loopContext: AgentLoopContext = this.config;
+ const toolRegistry = loopContext.toolRegistry;
+ const mcpServers = this.config.getMcpClientManager()?.getMcpServers() || {};
+ const serverStatuses = getAllMCPServerStatuses();
+ const servers = Object.keys(mcpServers).map((serverName) => ({
+ name: serverName,
+ status: serverStatuses.get(serverName) || MCPServerStatus.DISCONNECTED,
+ tools: toolRegistry.getToolsByServer(serverName).map((tool) => ({
+ name: tool.name,
+ description: tool.description,
+ parameterSchema: tool.schema.parameters,
+ })),
+ }));
+
+ const availableTools = toolRegistry.getAllTools().map((tool) => ({
+ name: tool.name,
+ description: tool.description,
+ parameterSchema: tool.schema.parameters,
+ }));
+
+ const metadata: TaskMetadata = {
+ id: this.id,
+ contextId: this.contextId,
+ taskState: this.taskState,
+ model: this.modelInfo || this.config.getModel(),
+ mcpServers: servers,
+ availableTools,
+ };
+ return metadata;
+ }
+
+ private _registerToolCall(toolCallId: string, status: string): void {
+ this.pendingToolCalls.set(toolCallId, status);
+ this.toolUpdateEmitter.emit('update');
+ logger.info(
+ `[Task] Registered tool call: ${toolCallId}. Pending: ${this.pendingToolCalls.size}`,
+ );
+ }
+
+ private _resolveToolCall(toolCallId: string): void {
+ if (this.pendingToolCalls.has(toolCallId)) {
+ this.pendingToolCalls.delete(toolCallId);
+ this.toolUpdateEmitter.emit('update');
+ logger.info(
+ `[Task] Resolved tool call: ${toolCallId}. Pending: ${this.pendingToolCalls.size}`,
+ );
+ }
+ }
+
+ private isAwaitingApprovalOnly(): boolean {
+ if (this.pendingToolCalls.size === 0) {
+ return false;
+ }
+ for (const [callId, status] of this.pendingToolCalls.entries()) {
+ if (
+ status !== CoreToolCallStatus.AwaitingApproval ||
+ this.toolsAlreadyConfirmed.has(callId)
+ ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ async waitForPendingTools(): Promise {
+ while (this.pendingToolCalls.size > 0 && !this.isAwaitingApprovalOnly()) {
+ if (this.cancellationError) {
+ const error = this.cancellationError;
+ this.cancellationError = undefined;
+ throw error;
+ }
+ logger.info(
+ `[Task] Waiting for ${this.pendingToolCalls.size} pending tool(s)...`,
+ );
+ await new Promise((resolve) =>
+ this.toolUpdateEmitter.once('update', resolve),
+ );
+ }
+ if (this.cancellationError) {
+ const error = this.cancellationError;
+ this.cancellationError = undefined;
+ throw error;
+ }
+ }
+
+ cancelPendingTools(reason: string): void {
+ if (this.pendingToolCalls.size > 0) {
+ logger.info(
+ `[Task] Cancelling all ${this.pendingToolCalls.size} pending tool calls. Reason: ${reason}`,
+ );
+ }
+ this.cancellationError = new Error(reason);
+ this.pendingToolCalls.clear();
+ this.pendingCorrelationIds.clear();
+ this.toolsAlreadyConfirmed.clear();
+
+ this.scheduler.cancelAll();
+ this.toolUpdateEmitter.emit('update');
+ }
+
+ private _createTextMessage(
+ text: string,
+ role: 'agent' | 'user' = 'agent',
+ ): Message {
+ return {
+ kind: 'message',
+ role,
+ parts: [{ kind: 'text', text }],
+ messageId: role === 'agent' ? this.currentAgentMessageId : uuidv4(),
+ taskId: this.id,
+ contextId: this.contextId,
+ };
+ }
+
+ private _createStatusUpdateEvent(
+ stateToReport: TaskState,
+ coderAgentMessage: CoderAgentMessage,
+ message?: Message,
+ final = false,
+ timestamp?: string,
+ metadataError?: string,
+ traceId?: string,
+ ): TaskStatusUpdateEvent {
+ const metadata: {
+ coderAgent: CoderAgentMessage;
+ model: string;
+ userTier?: UserTierId;
+ error?: string;
+ traceId?: string;
+ usageMetadata?: Task['usageMetadata'];
+ } = {
+ coderAgent: coderAgentMessage,
+ model: this.modelInfo || this.config.getModel(),
+ userTier: this.config.getUserTier(),
+ };
+
+ if (metadataError) {
+ metadata.error = metadataError;
+ }
+
+ if (traceId) {
+ metadata.traceId = traceId;
+ }
+
+ if (final && this.usageMetadata) {
+ metadata.usageMetadata = this.usageMetadata;
+ }
+
+ return {
+ kind: 'status-update',
+ taskId: this.id,
+ contextId: this.contextId,
+ status: {
+ state: stateToReport,
+ message, // Shorthand property
+ timestamp: timestamp || new Date().toISOString(),
+ },
+ final,
+ metadata,
+ };
+ }
+
+ setTaskStateAndPublishUpdate(
+ newState: TaskState,
+ coderAgentMessage: CoderAgentMessage,
+ messageText?: string,
+ messageParts?: Part[], // For more complex messages
+ final = false,
+ metadataError?: string,
+ traceId?: string,
+ ): void {
+ this.taskState = newState;
+ let message: Message | undefined;
+
+ if (messageText) {
+ message = this._createTextMessage(messageText);
+ } else if (messageParts) {
+ message = {
+ kind: 'message',
+ role: 'agent',
+ parts: messageParts,
+ messageId: uuidv4(),
+ taskId: this.id,
+ contextId: this.contextId,
+ };
+ }
+
+ const event = this._createStatusUpdateEvent(
+ this.taskState,
+ coderAgentMessage,
+ message,
+ final,
+ undefined,
+ metadataError,
+ traceId,
+ );
+ this.eventBus?.publish(event);
+ }
+
+ private _schedulerOutputUpdate(
+ toolCallId: string,
+ outputChunk: ToolLiveOutput,
+ ): void {
+ let outputAsText: string;
+ if (typeof outputChunk === 'string') {
+ outputAsText = outputChunk;
+ } else if (isSubagentProgress(outputChunk)) {
+ outputAsText = JSON.stringify(outputChunk);
+ } else if (Array.isArray(outputChunk)) {
+ const ansiOutput: AnsiOutput = outputChunk;
+ outputAsText = ansiOutput
+ .map((line: AnsiLine) =>
+ line.map((token: AnsiToken) => token.text).join(''),
+ )
+ .join('\n');
+ } else {
+ outputAsText = String(outputChunk);
+ }
+
+ logger.info(
+ '[Task] Scheduler output update for tool call ' +
+ toolCallId +
+ ': ' +
+ outputAsText,
+ );
+ const artifact: Artifact = {
+ artifactId: `tool-${toolCallId}-output`,
+ parts: [
+ {
+ kind: 'text',
+ text: outputAsText,
+ } as Part,
+ ],
+ };
+ const artifactEvent: TaskArtifactUpdateEvent = {
+ kind: 'artifact-update',
+ taskId: this.id,
+ contextId: this.contextId,
+ artifact,
+ append: true,
+ lastChunk: false,
+ };
+ this.eventBus?.publish(artifactEvent);
+ }
+
+ private messageBusListener?: (message: ToolCallsUpdateMessage) => void;
+
+ private setupEventDrivenScheduler(): Scheduler {
+ const loopContext: AgentLoopContext = this.config;
+ const messageBus = loopContext.messageBus;
+ const scheduler = new Scheduler({
+ schedulerId: this.id,
+ context: this.config,
+ messageBus,
+ getPreferredEditor: () => DEFAULT_GUI_EDITOR,
+ });
+
+ this.messageBusListener = this.handleEventDrivenToolCallsUpdate.bind(this);
+ messageBus.subscribe(
+ MessageBusType.TOOL_CALLS_UPDATE,
+ this.messageBusListener,
+ );
+
+ return scheduler;
+ }
+
+ dispose(): void {
+ if (this.messageBusListener) {
+ const loopContext: AgentLoopContext = this.config;
+ loopContext.messageBus.unsubscribe(
+ MessageBusType.TOOL_CALLS_UPDATE,
+ this.messageBusListener,
+ );
+ this.messageBusListener = undefined;
+ }
+
+ this.scheduler.dispose();
+ }
+
+ private handleEventDrivenToolCallsUpdate(
+ event: ToolCallsUpdateMessage,
+ ): void {
+ if (
+ event.type !== MessageBusType.TOOL_CALLS_UPDATE ||
+ event.schedulerId !== this.id
+ ) {
+ return;
+ }
+
+ const toolCalls = event.toolCalls;
+
+ toolCalls.forEach((tc) => {
+ this.handleEventDrivenToolCall(tc);
+ });
+
+ this.checkInputRequiredState();
+ }
+
+ private handleEventDrivenToolCall(tc: ToolCall): boolean {
+ const callId = tc.request.callId;
+
+ // Do not process events for tools that have already been finalized.
+ // This prevents duplicate completions if the state manager emits a snapshot containing
+ // already resolved tools whose IDs were removed from pendingToolCalls.
+ if (
+ this.processedToolCallIds.has(callId) ||
+ this.completedToolCalls.some((c) => c.request.callId === callId)
+ ) {
+ return false;
+ }
+
+ const previousStatus = this.pendingToolCalls.get(callId);
+ const previousOutcome = this.pendingOutcomes.get(callId);
+ const hasChanged =
+ previousStatus !== tc.status || previousOutcome !== tc.outcome;
+
+ // Update outcome tracking
+ this.pendingOutcomes.set(callId, tc.outcome);
+
+ // 1. Handle Output
+ if (tc.status === 'executing' && tc.liveOutput) {
+ this._schedulerOutputUpdate(callId, tc.liveOutput);
+ }
+
+ // 2. Handle terminal states
+ if (
+ tc.status === 'success' ||
+ tc.status === 'error' ||
+ tc.status === 'cancelled'
+ ) {
+ this.toolsAlreadyConfirmed.delete(callId);
+ this.pendingOutcomes.delete(callId);
+ if (hasChanged) {
+ logger.info(
+ `[Task] Tool call ${callId} completed with status: ${tc.status}`,
+ );
+ this.completedToolCalls.push(tc);
+ this._resolveToolCall(callId);
+ }
+ } else {
+ // Keep track of pending tools
+ this._registerToolCall(callId, tc.status);
+ }
+
+ // 3. Handle Confirmation Stash
+ if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
+ const details = tc.confirmationDetails;
+
+ if (tc.correlationId) {
+ this.pendingCorrelationIds.set(callId, tc.correlationId);
+ }
+
+ this.pendingToolConfirmationDetails.set(callId, {
+ ...details,
+ onConfirm: async () => {},
+ } as ToolCallConfirmationDetails);
+ }
+
+ // 4. Publish Status Updates to A2A event bus
+ if (hasChanged) {
+ const coderAgentMessage: CoderAgentMessage =
+ tc.status === 'awaiting_approval'
+ ? { kind: CoderAgentEvent.ToolCallConfirmationEvent }
+ : { kind: CoderAgentEvent.ToolCallUpdateEvent };
+
+ const message = this.toolStatusMessage(tc, this.id, this.contextId);
+ const statusUpdate = this._createStatusUpdateEvent(
+ this.taskState,
+ coderAgentMessage,
+ message,
+ false,
+ );
+ this.eventBus?.publish(statusUpdate);
+ }
+
+ return hasChanged;
+ }
+
+ private checkInputRequiredState(): void {
+ if (this.isYoloMatch) {
+ return;
+ }
+
+ // 6. Handle Input Required State
+ let isAwaitingApproval = false;
+ let isExecuting = false;
+
+ for (const [callId, status] of this.pendingToolCalls.entries()) {
+ if (
+ status === CoreToolCallStatus.Executing ||
+ status === CoreToolCallStatus.Scheduled ||
+ status === CoreToolCallStatus.Validating ||
+ this.toolsAlreadyConfirmed.has(callId)
+ ) {
+ isExecuting = true;
+ } else if (status === CoreToolCallStatus.AwaitingApproval) {
+ isAwaitingApproval = true;
+ }
+ }
+
+ if (
+ isAwaitingApproval &&
+ !isExecuting &&
+ !this.skipFinalTrueAfterInlineEdit
+ ) {
+ this.skipFinalTrueAfterInlineEdit = false;
+ const wasAlreadyInputRequired = this.taskState === 'input-required';
+
+ this.setTaskStateAndPublishUpdate(
+ 'input-required',
+ { kind: CoderAgentEvent.StateChangeEvent },
+ undefined,
+ undefined,
+ /*final*/ true,
+ );
+
+ // Unblock waitForPendingTools to correctly end the executor loop and release the HTTP response stream.
+ // The IDE client will open a new stream with the confirmation reply.
+ if (!wasAlreadyInputRequired) {
+ this.toolUpdateEmitter.emit('update');
+ }
+ }
+ }
+
+ private _pickFields<
+ T extends ToolCall | AnyDeclarativeTool,
+ K extends UnionKeys,
+ >(from: T, ...fields: K[]): Partial {
+ const ret: Partial = {};
+ for (const field of fields) {
+ if (field in from && from[field] !== undefined) {
+ ret[field] = from[field];
+ }
+ }
+ return ret;
+ }
+
+ private toolStatusMessage(
+ tc: ToolCall,
+ taskId: string,
+ contextId: string,
+ ): Message {
+ const messageParts: Part[] = [];
+
+ // Create a serializable version of the ToolCall (pick necessary
+ // properties/avoid methods causing circular reference errors).
+ // Type allows tool to be Partial for serialization.
+ const serializableToolCall: Partial> & {
+ tool?: Partial;
+ } = this._pickFields(
+ tc,
+ 'request',
+ 'status',
+ 'confirmationDetails',
+ 'liveOutput',
+ 'response',
+ 'outcome',
+ );
+
+ // Map internal 'validating' status to 'scheduled' for the client
+ if (serializableToolCall.status === CoreToolCallStatus.Validating) {
+ serializableToolCall.status = CoreToolCallStatus.Scheduled;
+ }
+
+ if (tc.tool) {
+ const toolFields = this._pickFields(
+ tc.tool,
+ 'name',
+ 'displayName',
+ 'description',
+ 'kind',
+ 'isOutputMarkdown',
+ 'canUpdateOutput',
+ 'schema',
+ 'parameterSchema',
+ );
+ serializableToolCall.tool = toolFields;
+ }
+
+ messageParts.push({
+ kind: 'data',
+ data: serializableToolCall,
+ } as Part);
+
+ return {
+ kind: 'message',
+ role: 'agent',
+ parts: messageParts,
+ messageId: uuidv4(),
+ taskId,
+ contextId,
+ };
+ }
+
+ private async getProposedContent(
+ file_path: string,
+ old_string: string,
+ new_string: string,
+ ): Promise {
+ // Validate path to prevent path traversal vulnerabilities
+ const resolvedPath = path.resolve(this.config.getTargetDir(), file_path);
+ const pathError = this.config.validatePathAccess(resolvedPath, 'read');
+ if (pathError) {
+ throw new Error(`Path validation failed: ${pathError}`);
+ }
+
+ try {
+ const rawContent = await fs.readFile(resolvedPath, 'utf8');
+ const hasCrlf = rawContent.includes('\r\n');
+ const currentContent = rawContent.replace(/\r\n/g, '\n');
+ const normalizedOldString = old_string.replace(/\r\n/g, '\n');
+ const normalizedNewString = new_string.replace(/\r\n/g, '\n');
+ const proposedContent = this._applyReplacement(
+ currentContent,
+ normalizedOldString,
+ normalizedNewString,
+ normalizedOldString === '' && currentContent === '',
+ );
+ return hasCrlf ? proposedContent.replace(/\n/g, '\r\n') : proposedContent;
+ } catch (err) {
+ if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
+ return '';
+ }
+ }
+
+ private _applyReplacement(
+ currentContent: string | null,
+ oldString: string,
+ newString: string,
+ isNewFile: boolean,
+ ): string {
+ if (isNewFile) {
+ return newString;
+ }
+ if (currentContent === null) {
+ // Should not happen if not a new file, but defensively return empty or newString if oldString is also empty
+ return oldString === '' ? newString : '';
+ }
+ // If oldString is empty and it's not a new file, do not modify the content.
+ if (oldString === '' && !isNewFile) {
+ return currentContent;
+ }
+
+ // Use intelligent replacement that handles $ sequences safely
+ return safeLiteralReplace(currentContent, oldString, newString);
+ }
+
+ async scheduleToolCalls(
+ requests: ToolCallRequestInfo[],
+ abortSignal: AbortSignal,
+ ): Promise {
+ if (requests.length === 0) {
+ return;
+ }
+
+ // Set checkpoint file before any file modification tool executes
+ const restorableToolCalls = requests.filter((request) =>
+ EDIT_TOOL_NAMES.has(request.name),
+ );
+
+ if (
+ restorableToolCalls.length > 0 &&
+ this.config.getCheckpointingEnabled()
+ ) {
+ const gitService = await this.config.getGitService();
+ if (gitService) {
+ const { checkpointsToWrite, toolCallToCheckpointMap, errors } =
+ await processRestorableToolCalls(
+ restorableToolCalls,
+ gitService,
+ this.geminiClient,
+ );
+
+ if (errors.length > 0) {
+ errors.forEach((error) => logger.error(error));
+ }
+
+ if (checkpointsToWrite.size > 0) {
+ const checkpointDir =
+ this.config.storage.getProjectTempCheckpointsDir();
+ await fs.mkdir(checkpointDir, { recursive: true });
+ for (const [fileName, content] of checkpointsToWrite) {
+ const filePath = path.join(checkpointDir, fileName);
+ await fs.writeFile(filePath, content);
+ }
+ }
+
+ for (const request of requests) {
+ const checkpoint = toolCallToCheckpointMap.get(request.callId);
+ if (checkpoint) {
+ request.checkpoint = checkpoint;
+ }
+ }
+ }
+ }
+
+ const updatedRequests = await Promise.all(
+ requests.map(async (request) => {
+ if (
+ request.name === 'replace' &&
+ request.args &&
+ !request.args['newContent'] &&
+ request.args['file_path'] &&
+ request.args['old_string'] &&
+ request.args['new_string']
+ ) {
+ const filePath = request.args['file_path'];
+ const oldString = request.args['old_string'];
+ const newString = request.args['new_string'];
+ if (
+ typeof filePath === 'string' &&
+ typeof oldString === 'string' &&
+ typeof newString === 'string'
+ ) {
+ // Resolve and validate path to prevent path traversal (user-controlled file_path).
+ const resolvedPath = path.resolve(
+ this.config.getTargetDir(),
+ filePath,
+ );
+ const pathError = this.config.validatePathAccess(
+ resolvedPath,
+ 'read',
+ );
+ if (!pathError) {
+ const newContent = await this.getProposedContent(
+ resolvedPath,
+ oldString,
+ newString,
+ );
+ return { ...request, args: { ...request.args, newContent } };
+ }
+ }
+ }
+ return request;
+ }),
+ );
+
+ logger.info(
+ `[Task] Scheduling batch of ${updatedRequests.length} tool calls.`,
+ );
+ const stateChange: StateChange = {
+ kind: CoderAgentEvent.StateChangeEvent,
+ };
+ this.setTaskStateAndPublishUpdate('working', stateChange);
+
+ // Pre-register tools to ensure waitForPendingTools sees them as pending
+ // before the async scheduler enqueues them and fires the event bus update.
+ for (const req of updatedRequests) {
+ if (!this.pendingToolCalls.has(req.callId)) {
+ this._registerToolCall(req.callId, 'scheduled');
+ }
+ }
+
+ // Fire and forget so we don't block the executor loop before waitForPendingTools can be called
+ void this.scheduler.schedule(updatedRequests, abortSignal);
+ }
+
+ async acceptAgentMessage(event: ServerGeminiStreamEvent): Promise {
+ const stateChange: StateChange = {
+ kind: CoderAgentEvent.StateChangeEvent,
+ };
+ const traceId =
+ 'traceId' in event && event.traceId ? event.traceId : undefined;
+
+ switch (event.type) {
+ case GeminiEventType.Content:
+ logger.info('[Task] Sending agent message content...');
+ this._sendTextContent(event.value, traceId);
+ break;
+ case GeminiEventType.ToolCallRequest:
+ // This is now handled by the agent loop, which collects all requests
+ // and calls scheduleToolCalls once.
+ logger.warn(
+ '[Task] A single tool call request was passed to acceptAgentMessage. This should be handled in a batch by the agent. Ignoring.',
+ );
+ break;
+ case GeminiEventType.ToolCallResponse:
+ // This event type from ServerGeminiStreamEvent might be for when LLM *generates* a tool response part.
+ // The actual execution result comes via user message.
+ logger.info(
+ '[Task] Received tool call response from LLM (part of generation):',
+ event.value,
+ );
+ break;
+ case GeminiEventType.ToolCallConfirmation:
+ // This is when LLM requests confirmation, not when user provides it.
+ logger.info(
+ '[Task] Received tool call confirmation request from LLM:',
+ event.value.request.callId,
+ );
+ this.pendingToolConfirmationDetails.set(
+ event.value.request.callId,
+ event.value.details,
+ );
+ // This will be handled by the scheduler and _schedulerToolCallsUpdate will set InputRequired if needed.
+ // No direct state change here, scheduler drives it.
+ break;
+ case GeminiEventType.UserCancelled:
+ logger.info('[Task] Received user cancelled event from LLM stream.');
+ this.cancelPendingTools('User cancelled via LLM stream event');
+ this.setTaskStateAndPublishUpdate(
+ 'input-required',
+ stateChange,
+ 'Task cancelled by user',
+ undefined,
+ true,
+ undefined,
+ traceId,
+ );
+ break;
+ case GeminiEventType.Thought:
+ logger.info('[Task] Sending agent thought...');
+ this._sendThought(event.value, traceId);
+ break;
+ case GeminiEventType.Citation:
+ logger.info('[Task] Received citation from LLM stream.');
+ this._sendCitation(event.value);
+ break;
+ case GeminiEventType.ChatCompressed:
+ break;
+ case GeminiEventType.Finished:
+ logger.info(`[Task ${this.id}] Agent finished its turn.`);
+ // Capture the usage metadata when the stream finishes
+ if (
+ event.value &&
+ typeof event.value === 'object' &&
+ 'usageMetadata' in event.value
+ ) {
+ this.usageMetadata = event.value
+ .usageMetadata as typeof this.usageMetadata;
+ }
+ break;
+ case GeminiEventType.ModelInfo:
+ this.usageMetadata = undefined;
+ this.modelInfo = event.value;
+ break;
+ case GeminiEventType.Retry:
+ case GeminiEventType.InvalidStream:
+ // An invalid stream should trigger a retry, which requires no action from the user.
+ break;
+ case GeminiEventType.Error:
+ default: {
+ // Use type guard instead of unsafe type assertion
+ let errorEvent: ServerGeminiErrorEvent | undefined;
+ if (
+ event.type === GeminiEventType.Error &&
+ event.value &&
+ typeof event.value === 'object' &&
+ 'error' in event.value
+ ) {
+ errorEvent = event;
+ }
+ const errorMessage = errorEvent?.value?.error
+ ? getErrorMessage(errorEvent.value.error)
+ : 'Unknown error from LLM stream';
+ logger.error(
+ '[Task] Received error event from LLM stream:',
+ errorMessage,
+ );
+
+ let errMessage = `Unknown error from LLM stream: ${JSON.stringify(event)}`;
+ if (errorEvent?.value?.error) {
+ errMessage = parseAndFormatApiError(errorEvent.value.error);
+ }
+ this.cancelPendingTools(`LLM stream error: ${errorMessage}`);
+ this.setTaskStateAndPublishUpdate(
+ this.taskState,
+ stateChange,
+ `Agent Error, unknown agent message: ${errorMessage}`,
+ undefined,
+ false,
+ errMessage,
+ traceId,
+ );
+ break;
+ }
+ }
+ }
+
+ private async _handleToolConfirmationPart(part: Part): Promise {
+ if (
+ part.kind !== 'data' ||
+ !part.data ||
+ // eslint-disable-next-line no-restricted-syntax
+ typeof part.data['callId'] !== 'string' ||
+ // eslint-disable-next-line no-restricted-syntax
+ typeof part.data['outcome'] !== 'string'
+ ) {
+ return false;
+ }
+ if (!part.data['outcome']) {
+ return false;
+ }
+
+ const callId = part.data['callId'];
+ const outcomeString = part.data['outcome'];
+
+ this.toolsAlreadyConfirmed.add(callId);
+ this.toolUpdateEmitter.emit('update');
+
+ let confirmationOutcome: ToolConfirmationOutcome | undefined;
+
+ if (outcomeString === 'proceed_once') {
+ confirmationOutcome = ToolConfirmationOutcome.ProceedOnce;
+ } else if (outcomeString === 'cancel') {
+ confirmationOutcome = ToolConfirmationOutcome.Cancel;
+ } else if (outcomeString === 'proceed_always') {
+ confirmationOutcome = ToolConfirmationOutcome.ProceedAlways;
+ } else if (outcomeString === 'proceed_always_server') {
+ confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysServer;
+ } else if (outcomeString === 'proceed_always_tool') {
+ confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysTool;
+ } else if (outcomeString === 'proceed_always_and_save') {
+ confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysAndSave;
+ } else if (outcomeString === 'modify_with_editor') {
+ confirmationOutcome = ToolConfirmationOutcome.ModifyWithEditor;
+ } else {
+ logger.warn(
+ `[Task] Unknown tool confirmation outcome: "${outcomeString}" for callId: ${callId}`,
+ );
+ return false;
+ }
+
+ const confirmationDetails = this.pendingToolConfirmationDetails.get(callId);
+ const correlationId = this.pendingCorrelationIds.get(callId);
+
+ if (!confirmationDetails && !correlationId) {
+ logger.warn(
+ `[Task] Received tool confirmation for unknown or already processed callId: ${callId}`,
+ );
+ return false;
+ }
+
+ logger.info(
+ `[Task] Handling tool confirmation for callId: ${callId} with outcome: ${outcomeString}`,
+ );
+ try {
+ // Temporarily unset GCP environment variables so they do not leak into
+ // tool calls.
+ const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'];
+ const gcpCreds = process.env['GOOGLE_APPLICATION_CREDENTIALS'];
+ try {
+ delete process.env['GOOGLE_CLOUD_PROJECT'];
+ delete process.env['GOOGLE_APPLICATION_CREDENTIALS'];
+
+ // This will trigger the scheduler to continue or cancel the specific tool.
+ // The scheduler's onToolCallsUpdate will then reflect the new state (e.g., executing or cancelled).
+
+ // If `edit` tool call, pass updated payload if present
+ const newContent = part.data['newContent'];
+ const payload =
+ confirmationDetails?.type === 'edit' && typeof newContent === 'string'
+ ? ({ newContent } as ToolConfirmationPayload)
+ : undefined;
+ this.skipFinalTrueAfterInlineEdit = !!payload;
+
+ try {
+ if (correlationId) {
+ const loopContext: AgentLoopContext = this.config;
+ await loopContext.messageBus.publish({
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
+ correlationId,
+ confirmed:
+ confirmationOutcome !== ToolConfirmationOutcome.Cancel &&
+ confirmationOutcome !==
+ ToolConfirmationOutcome.ModifyWithEditor,
+ outcome: confirmationOutcome,
+ payload,
+ });
+ } else if (confirmationDetails?.onConfirm) {
+ // Fallback for legacy callback-based confirmation
+ await confirmationDetails.onConfirm(confirmationOutcome, payload);
+ }
+ } finally {
+ // Once confirmation payload is sent or callback finishes,
+ // reset skipFinalTrueAfterInlineEdit so that external callers receive
+ // their call has been completed.
+ this.skipFinalTrueAfterInlineEdit = false;
+ }
+ } finally {
+ if (gcpProject) {
+ process.env['GOOGLE_CLOUD_PROJECT'] = gcpProject;
+ }
+ if (gcpCreds) {
+ process.env['GOOGLE_APPLICATION_CREDENTIALS'] = gcpCreds;
+ }
+ }
+
+ // Do not delete if modifying, a subsequent tool confirmation for the same
+ // callId will be passed with ProceedOnce/Cancel/etc
+ // Note !== ToolConfirmationOutcome.ModifyWithEditor does not work!
+ if (confirmationOutcome !== 'modify_with_editor') {
+ this.pendingToolConfirmationDetails.delete(callId);
+ this.pendingCorrelationIds.delete(callId);
+ }
+
+ // If outcome is Cancel, scheduler should update status to 'cancelled', which then resolves the tool.
+ // If ProceedOnce, scheduler updates to 'executing', then eventually 'success'/'error', which resolves.
+ return true;
+ } catch (error) {
+ logger.error(
+ `[Task] Error during tool confirmation for callId ${callId}:`,
+ error,
+ );
+ // If confirming fails, we should probably mark this tool as failed
+ this._resolveToolCall(callId); // Resolve it as it won't proceed.
+ const errorMessageText =
+ error instanceof Error
+ ? error.message
+ : `Error processing tool confirmation for ${callId}`;
+ const message = this._createTextMessage(errorMessageText);
+ const toolCallUpdate: ToolCallUpdate = {
+ kind: CoderAgentEvent.ToolCallUpdateEvent,
+ };
+ const event = this._createStatusUpdateEvent(
+ this.taskState,
+ toolCallUpdate,
+ message,
+ false,
+ );
+ this.eventBus?.publish(event);
+ return false;
+ }
+ }
+
+ getAndClearCompletedTools(): CompletedToolCall[] {
+ const tools = [...this.completedToolCalls];
+ for (const tool of tools) {
+ this.processedToolCallIds.add(tool.request.callId);
+ }
+ this.completedToolCalls = [];
+ return tools;
+ }
+
+ addToolResponsesToHistory(completedTools: CompletedToolCall[]): void {
+ logger.info(
+ `[Task] Adding ${completedTools.length} tool responses to history without generating a new response.`,
+ );
+ const parts: genAiPart[] = [];
+ for (const toolCall of completedTools) {
+ const response = toolCall.response?.responseParts;
+ if (!response) {
+ continue;
+ }
+ if (Array.isArray(response)) {
+ parts.push(...response);
+ } else if (typeof response === 'string') {
+ parts.push({ text: response });
+ } else {
+ parts.push(response);
+ }
+ }
+ if (parts.length > 0) {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ this.geminiClient.addHistory({
+ role: 'user',
+ parts,
+ });
+ }
+ }
+
+ async *sendCompletedToolsToLlm(
+ completedToolCalls: CompletedToolCall[],
+ aborted: AbortSignal,
+ ): AsyncGenerator {
+ if (completedToolCalls.length === 0) {
+ yield* (async function* () {})(); // Yield nothing
+ return;
+ }
+
+ const llmParts: PartUnion[] = [];
+ logger.info(
+ `[Task] Feeding ${completedToolCalls.length} tool responses to LLM.`,
+ );
+ for (const completedToolCall of completedToolCalls) {
+ logger.info(
+ `[Task] Adding tool response for "${completedToolCall.request.name}" (callId: ${completedToolCall.request.callId}) to LLM input.`,
+ );
+ const responseParts = completedToolCall.response.responseParts;
+ if (Array.isArray(responseParts)) {
+ llmParts.push(...responseParts);
+ } else {
+ llmParts.push(responseParts);
+ }
+ }
+
+ logger.info('[Task] Sending new parts to agent.');
+ const stateChange: StateChange = {
+ kind: CoderAgentEvent.StateChangeEvent,
+ };
+ // Set task state to working as we are about to call LLM
+ this.setTaskStateAndPublishUpdate('working', stateChange);
+ this.currentAgentMessageId = uuidv4();
+ yield* this.geminiClient.sendMessageStream(
+ llmParts,
+ aborted,
+ completedToolCalls[0]?.request.prompt_id ?? '',
+ );
+ }
+
+ async *acceptUserMessage(
+ requestContext: RequestContext,
+ aborted: AbortSignal,
+ ): AsyncGenerator {
+ // Clear any stale cancellation error from a previous aborted execution loop
+ this.cancellationError = undefined;
+
+ const userMessage = requestContext.userMessage;
+ const llmParts: PartUnion[] = [];
+ let anyConfirmationHandled = false;
+ let hasContentForLlm = false;
+
+ for (const part of userMessage.parts) {
+ const confirmationHandled = await this._handleToolConfirmationPart(part);
+ if (confirmationHandled) {
+ anyConfirmationHandled = true;
+ // If a confirmation was handled, the scheduler will now run the tool (or cancel it).
+ // We don't send anything to the LLM for this part.
+ // The subsequent tool execution will eventually lead to resolveToolCall.
+ continue;
+ }
+
+ if (part.kind === 'text') {
+ llmParts.push({ text: part.text });
+ hasContentForLlm = true;
+ }
+ }
+
+ if (hasContentForLlm) {
+ this.currentPromptId =
+ this.config.getSessionId() + '########' + this.promptCount++;
+ this.currentAgentMessageId = uuidv4();
+ logger.info('[Task] Sending new parts to LLM.');
+ const stateChange: StateChange = {
+ kind: CoderAgentEvent.StateChangeEvent,
+ };
+ // Set task state to working as we are about to call LLM
+ this.setTaskStateAndPublishUpdate('working', stateChange);
+ yield* this.geminiClient.sendMessageStream(
+ llmParts,
+ aborted,
+ this.currentPromptId,
+ );
+ } else if (anyConfirmationHandled) {
+ logger.info(
+ '[Task] User message only contained tool confirmations. Scheduler is active. No new input for LLM this turn.',
+ );
+ // Ensure task state reflects that scheduler might be working due to confirmation.
+ // If scheduler is active, it will emit its own status updates.
+ // If all pending tools were just confirmed, waitForPendingTools will handle the wait.
+ // If some tools are still pending approval, scheduler would have set InputRequired.
+ // If not, and no new text, we are just waiting.
+ if (
+ this.pendingToolCalls.size > 0 &&
+ this.taskState !== 'input-required'
+ ) {
+ const stateChange: StateChange = {
+ kind: CoderAgentEvent.StateChangeEvent,
+ };
+ this.setTaskStateAndPublishUpdate('working', stateChange); // Reflect potential background activity
+ }
+ yield* (async function* () {})(); // Yield nothing
+ } else {
+ logger.info(
+ '[Task] No relevant parts in user message for LLM interaction or tool confirmation.',
+ );
+ // If there's no new text and no confirmations, and no pending tools,
+ // it implies we might need to signal input required if nothing else is happening.
+ // However, the agent.ts will make this determination after waitForPendingTools.
+ yield* (async function* () {})(); // Yield nothing
+ }
+ }
+
+ _sendTextContent(content: string, traceId?: string): void {
+ if (content === '') {
+ return;
+ }
+ const message = this._createTextMessage(content);
+ const textContent: TextContent = {
+ kind: CoderAgentEvent.TextContentEvent,
+ };
+ this.eventBus?.publish(
+ this._createStatusUpdateEvent(
+ this.taskState,
+ textContent,
+ message,
+ false,
+ undefined,
+ undefined,
+ traceId,
+ ),
+ );
+ }
+
+ _sendThought(content: ThoughtSummary, traceId?: string): void {
+ if (!content.subject && !content.description) {
+ return;
+ }
+ logger.info('[Task] Sending thought to event bus.');
+ const message: Message = {
+ kind: 'message',
+ role: 'agent',
+ parts: [
+ {
+ kind: 'data',
+ data: content,
+ } as Part,
+ ],
+ messageId: this.currentAgentMessageId,
+ taskId: this.id,
+ contextId: this.contextId,
+ };
+ const thought: Thought = {
+ kind: CoderAgentEvent.ThoughtEvent,
+ };
+ this.eventBus?.publish(
+ this._createStatusUpdateEvent(
+ this.taskState,
+ thought,
+ message,
+ false,
+ undefined,
+ undefined,
+ traceId,
+ ),
+ );
+ }
+
+ _sendCitation(citation: string) {
+ if (!citation || citation.trim() === '') {
+ return;
+ }
+ logger.info('[Task] Sending citation to event bus.');
+ const message = this._createTextMessage(citation);
+ const citationEvent: Citation = {
+ kind: CoderAgentEvent.CitationEvent,
+ };
+ this.eventBus?.publish(
+ this._createStatusUpdateEvent(this.taskState, citationEvent, message),
+ );
+ }
+}
diff --git a/packages/a2a-server/src/commands/command-registry.test.ts b/packages/a2a-server/src/commands/command-registry.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7a6b1e809115e001c8e2c7bbe8d05b526f5f05d2
--- /dev/null
+++ b/packages/a2a-server/src/commands/command-registry.test.ts
@@ -0,0 +1,142 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import type { Command } from './types.js';
+
+const {
+ mockExtensionsCommand,
+ mockListExtensionsCommand,
+ mockExtensionsCommandInstance,
+ mockListExtensionsCommandInstance,
+} = vi.hoisted(() => {
+ const listInstance: Command = {
+ name: 'extensions list',
+ description: 'Lists all installed extensions.',
+ execute: vi.fn(),
+ };
+
+ const extInstance: Command = {
+ name: 'extensions',
+ description: 'Manage extensions.',
+ execute: vi.fn(),
+ subCommands: [listInstance],
+ };
+
+ return {
+ mockListExtensionsCommandInstance: listInstance,
+ mockExtensionsCommandInstance: extInstance,
+ mockExtensionsCommand: vi.fn(() => extInstance),
+ mockListExtensionsCommand: vi.fn(() => listInstance),
+ };
+});
+
+vi.mock('./extensions.js', () => ({
+ ExtensionsCommand: mockExtensionsCommand,
+ ListExtensionsCommand: mockListExtensionsCommand,
+}));
+
+vi.mock('./init.js', () => ({
+ InitCommand: vi.fn(() => ({
+ name: 'init',
+ description: 'Initializes the server.',
+ execute: vi.fn(),
+ })),
+}));
+
+vi.mock('./restore.js', () => ({
+ RestoreCommand: vi.fn(() => ({
+ name: 'restore',
+ description: 'Restores the server.',
+ execute: vi.fn(),
+ })),
+}));
+
+import { commandRegistry } from './command-registry.js';
+
+describe('CommandRegistry', () => {
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ commandRegistry.initialize();
+ });
+
+ it('should register ExtensionsCommand on initialization', async () => {
+ expect(mockExtensionsCommand).toHaveBeenCalled();
+ const command = commandRegistry.get('extensions');
+ expect(command).toBe(mockExtensionsCommandInstance);
+ }, 20000);
+
+ it('should register sub commands on initialization', async () => {
+ const command = commandRegistry.get('extensions list');
+ expect(command).toBe(mockListExtensionsCommandInstance);
+ });
+
+ it('get() should return undefined for a non-existent command', async () => {
+ const command = commandRegistry.get('non-existent');
+ expect(command).toBeUndefined();
+ });
+
+ it('register() should register a new command', async () => {
+ const mockCommand: Command = {
+ name: 'test-command',
+ description: '',
+ execute: vi.fn(),
+ };
+ commandRegistry.register(mockCommand);
+ const command = commandRegistry.get('test-command');
+ expect(command).toBe(mockCommand);
+ });
+
+ it('register() should register a nested command', async () => {
+ const mockSubSubCommand: Command = {
+ name: 'test-command-sub-sub',
+ description: '',
+ execute: vi.fn(),
+ };
+ const mockSubCommand: Command = {
+ name: 'test-command-sub',
+ description: '',
+ execute: vi.fn(),
+ subCommands: [mockSubSubCommand],
+ };
+ const mockCommand: Command = {
+ name: 'test-command',
+ description: '',
+ execute: vi.fn(),
+ subCommands: [mockSubCommand],
+ };
+ commandRegistry.register(mockCommand);
+
+ const command = commandRegistry.get('test-command');
+ const subCommand = commandRegistry.get('test-command-sub');
+ const subSubCommand = commandRegistry.get('test-command-sub-sub');
+
+ expect(command).toBe(mockCommand);
+ expect(subCommand).toBe(mockSubCommand);
+ expect(subSubCommand).toBe(mockSubSubCommand);
+ });
+
+ it('register() should not enter an infinite loop with a cyclic command', async () => {
+ const { debugLogger } = await import('@google/gemini-cli-core');
+ const warnSpy = vi.spyOn(debugLogger, 'warn').mockImplementation(() => {});
+ const mockCommand: Command = {
+ name: 'cyclic-command',
+ description: '',
+ subCommands: [],
+ execute: vi.fn(),
+ };
+
+ mockCommand.subCommands?.push(mockCommand); // Create cycle
+
+ commandRegistry.register(mockCommand);
+
+ expect(commandRegistry.get('cyclic-command')).toBe(mockCommand);
+ expect(warnSpy).toHaveBeenCalledWith(
+ 'Command cyclic-command already registered. Skipping.',
+ );
+ warnSpy.mockRestore();
+ });
+});
diff --git a/packages/a2a-server/src/commands/command-registry.ts b/packages/a2a-server/src/commands/command-registry.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e9cd75b11a1791bdebec7025227eb827d4ede8f5
--- /dev/null
+++ b/packages/a2a-server/src/commands/command-registry.ts
@@ -0,0 +1,51 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { MemoryCommand } from './memory.js';
+import { debugLogger } from '@google/gemini-cli-core';
+import { ExtensionsCommand } from './extensions.js';
+import { InitCommand } from './init.js';
+import { RestoreCommand } from './restore.js';
+import type { Command } from './types.js';
+
+export class CommandRegistry {
+ private readonly commands = new Map();
+
+ constructor() {
+ this.initialize();
+ }
+
+ initialize() {
+ this.commands.clear();
+ this.register(new ExtensionsCommand());
+ this.register(new RestoreCommand());
+ this.register(new InitCommand());
+ this.register(new MemoryCommand());
+ }
+
+ register(command: Command) {
+ if (this.commands.has(command.name)) {
+ debugLogger.warn(`Command ${command.name} already registered. Skipping.`);
+ return;
+ }
+
+ this.commands.set(command.name, command);
+
+ for (const subCommand of command.subCommands ?? []) {
+ this.register(subCommand);
+ }
+ }
+
+ get(commandName: string): Command | undefined {
+ return this.commands.get(commandName);
+ }
+
+ getAllCommands(): Command[] {
+ return [...this.commands.values()];
+ }
+}
+
+export const commandRegistry = new CommandRegistry();
diff --git a/packages/a2a-server/src/commands/extensions.test.ts b/packages/a2a-server/src/commands/extensions.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..611c51b161af7c5723f33292fca2fb08adfd3487
--- /dev/null
+++ b/packages/a2a-server/src/commands/extensions.test.ts
@@ -0,0 +1,87 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { ExtensionsCommand, ListExtensionsCommand } from './extensions.js';
+import type { CommandContext } from './types.js';
+
+const mockListExtensions = vi.hoisted(() => vi.fn());
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const original =
+ await importOriginal();
+
+ return {
+ ...original,
+ listExtensions: mockListExtensions,
+ };
+});
+
+describe('ExtensionsCommand', () => {
+ it('should have the correct name', () => {
+ const command = new ExtensionsCommand();
+ expect(command.name).toEqual('extensions');
+ });
+
+ it('should have the correct description', () => {
+ const command = new ExtensionsCommand();
+ expect(command.description).toEqual('Manage extensions.');
+ });
+
+ it('should have "extensions list" as a subcommand', () => {
+ const command = new ExtensionsCommand();
+ expect(command.subCommands.map((c) => c.name)).toContain('extensions list');
+ });
+
+ it('should be a top-level command', () => {
+ const command = new ExtensionsCommand();
+ expect(command.topLevel).toBe(true);
+ });
+
+ it('should default to listing extensions', async () => {
+ const command = new ExtensionsCommand();
+ const mockConfig = { config: {} } as CommandContext;
+ const mockExtensions = [{ name: 'ext1' }];
+ mockListExtensions.mockReturnValue(mockExtensions);
+
+ const result = await command.execute(mockConfig, []);
+
+ expect(result).toEqual({ name: 'extensions list', data: mockExtensions });
+ expect(mockListExtensions).toHaveBeenCalledWith(mockConfig.config);
+ });
+});
+
+describe('ListExtensionsCommand', () => {
+ it('should have the correct name', () => {
+ const command = new ListExtensionsCommand();
+ expect(command.name).toEqual('extensions list');
+ });
+
+ it('should call listExtensions with the provided config', async () => {
+ const command = new ListExtensionsCommand();
+ const mockConfig = { config: {} } as CommandContext;
+ const mockExtensions = [{ name: 'ext1' }];
+ mockListExtensions.mockReturnValue(mockExtensions);
+
+ const result = await command.execute(mockConfig, []);
+
+ expect(result).toEqual({ name: 'extensions list', data: mockExtensions });
+ expect(mockListExtensions).toHaveBeenCalledWith(mockConfig.config);
+ });
+
+ it('should return a message when no extensions are installed', async () => {
+ const command = new ListExtensionsCommand();
+ const mockConfig = { config: {} } as CommandContext;
+ mockListExtensions.mockReturnValue([]);
+
+ const result = await command.execute(mockConfig, []);
+
+ expect(result).toEqual({
+ name: 'extensions list',
+ data: 'No extensions installed.',
+ });
+ expect(mockListExtensions).toHaveBeenCalledWith(mockConfig.config);
+ });
+});
diff --git a/packages/a2a-server/src/commands/extensions.ts b/packages/a2a-server/src/commands/extensions.ts
new file mode 100644
index 0000000000000000000000000000000000000000..27149eb3c76b45241610fffca3f6fd4adf53dcdc
--- /dev/null
+++ b/packages/a2a-server/src/commands/extensions.ts
@@ -0,0 +1,41 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { listExtensions } from '@google/gemini-cli-core';
+import type {
+ Command,
+ CommandContext,
+ CommandExecutionResponse,
+} from './types.js';
+
+export class ExtensionsCommand implements Command {
+ readonly name = 'extensions';
+ readonly description = 'Manage extensions.';
+ readonly subCommands = [new ListExtensionsCommand()];
+ readonly topLevel = true;
+
+ async execute(
+ context: CommandContext,
+ _: string[],
+ ): Promise {
+ return new ListExtensionsCommand().execute(context, _);
+ }
+}
+
+export class ListExtensionsCommand implements Command {
+ readonly name = 'extensions list';
+ readonly description = 'Lists all installed extensions.';
+
+ async execute(
+ context: CommandContext,
+ _: string[],
+ ): Promise {
+ const extensions = listExtensions(context.config);
+ const data = extensions.length ? extensions : 'No extensions installed.';
+
+ return { name: this.name, data };
+ }
+}
diff --git a/packages/a2a-server/src/commands/init.test.ts b/packages/a2a-server/src/commands/init.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6280424fd7b12f0345420cb3eedecf0acc9bd580
--- /dev/null
+++ b/packages/a2a-server/src/commands/init.test.ts
@@ -0,0 +1,193 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { InitCommand } from './init.js';
+import {
+ performInit,
+ type CommandActionReturn,
+ type Config,
+} from '@google/gemini-cli-core';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { CoderAgentExecutor } from '../agent/executor.js';
+import { CoderAgentEvent } from '../types.js';
+import type { ExecutionEventBus } from '@a2a-js/sdk/server';
+import { createMockConfig } from '../utils/testing_utils.js';
+import type { CommandContext } from './types.js';
+import { logger } from '../utils/logger.js';
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ performInit: vi.fn(),
+ };
+});
+
+vi.mock('node:fs', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ existsSync: vi.fn(),
+ writeFileSync: vi.fn(),
+ };
+});
+
+vi.mock('../agent/executor.js', () => ({
+ CoderAgentExecutor: vi.fn().mockImplementation(() => ({
+ execute: vi.fn(),
+ })),
+}));
+
+vi.mock('../utils/logger.js', () => ({
+ logger: {
+ info: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+describe('InitCommand', () => {
+ let eventBus: ExecutionEventBus;
+ let command: InitCommand;
+ let context: CommandContext;
+ let publishSpy: ReturnType;
+ let mockExecute: ReturnType;
+ const mockWorkspacePath = path.resolve('/tmp');
+
+ beforeEach(() => {
+ vi.stubEnv('CODER_AGENT_WORKSPACE_PATH', mockWorkspacePath);
+ eventBus = {
+ publish: vi.fn(),
+ } as unknown as ExecutionEventBus;
+ command = new InitCommand();
+ const mockConfig = createMockConfig({
+ getModel: () => 'gemini-pro',
+ });
+ const mockExecutorInstance = new CoderAgentExecutor();
+ context = {
+ config: mockConfig as unknown as Config,
+ agentExecutor: mockExecutorInstance,
+ eventBus,
+ } as CommandContext;
+ publishSpy = vi.spyOn(eventBus, 'publish');
+ mockExecute = vi.fn();
+ vi.spyOn(mockExecutorInstance, 'execute').mockImplementation(mockExecute);
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('has requiresWorkspace set to true', () => {
+ expect(command.requiresWorkspace).toBe(true);
+ });
+
+ describe('execute', () => {
+ it('handles info from performInit', async () => {
+ vi.mocked(performInit).mockReturnValue({
+ type: 'message',
+ messageType: 'info',
+ content: 'GEMINI.md already exists.',
+ } as CommandActionReturn);
+
+ await command.execute(context, []);
+
+ expect(logger.info).toHaveBeenCalledWith(
+ '[EventBus event]: ',
+ expect.objectContaining({
+ kind: 'status-update',
+ status: expect.objectContaining({
+ state: 'completed',
+ message: expect.objectContaining({
+ parts: [{ kind: 'text', text: 'GEMINI.md already exists.' }],
+ }),
+ }),
+ }),
+ );
+
+ expect(publishSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kind: 'status-update',
+ status: expect.objectContaining({
+ state: 'completed',
+ message: expect.objectContaining({
+ parts: [{ kind: 'text', text: 'GEMINI.md already exists.' }],
+ }),
+ }),
+ }),
+ );
+ });
+
+ it('handles error from performInit', async () => {
+ vi.mocked(performInit).mockReturnValue({
+ type: 'message',
+ messageType: 'error',
+ content: 'An error occurred.',
+ } as CommandActionReturn);
+
+ await command.execute(context, []);
+
+ expect(publishSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kind: 'status-update',
+ status: expect.objectContaining({
+ state: 'failed',
+ message: expect.objectContaining({
+ parts: [{ kind: 'text', text: 'An error occurred.' }],
+ }),
+ }),
+ }),
+ );
+ });
+
+ describe('when handling submit_prompt', () => {
+ beforeEach(() => {
+ vi.mocked(performInit).mockReturnValue({
+ type: 'submit_prompt',
+ content: 'Create a new GEMINI.md file.',
+ } as CommandActionReturn);
+ });
+
+ it('writes the file and executes the agent', async () => {
+ await command.execute(context, []);
+
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
+ path.join(mockWorkspacePath, 'GEMINI.md'),
+ '',
+ 'utf8',
+ );
+ expect(mockExecute).toHaveBeenCalled();
+ });
+
+ it('passes autoExecute to the agent executor', async () => {
+ await command.execute(context, []);
+
+ expect(mockExecute).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userMessage: expect.objectContaining({
+ parts: expect.arrayContaining([
+ expect.objectContaining({
+ text: 'Create a new GEMINI.md file.',
+ }),
+ ]),
+ metadata: {
+ coderAgent: {
+ kind: CoderAgentEvent.StateAgentSettingsEvent,
+ workspacePath: mockWorkspacePath,
+ autoExecute: true,
+ },
+ },
+ }),
+ }),
+ eventBus,
+ );
+ });
+ });
+ });
+});
diff --git a/packages/a2a-server/src/commands/init.ts b/packages/a2a-server/src/commands/init.ts
new file mode 100644
index 0000000000000000000000000000000000000000..57697e1a241022630020eadc44f4a38bce9d7cb1
--- /dev/null
+++ b/packages/a2a-server/src/commands/init.ts
@@ -0,0 +1,169 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { CoderAgentEvent, type AgentSettings } from '../types.js';
+import { performInit } from '@google/gemini-cli-core';
+import type {
+ Command,
+ CommandContext,
+ CommandExecutionResponse,
+} from './types.js';
+import type { CoderAgentExecutor } from '../agent/executor.js';
+import type {
+ ExecutionEventBus,
+ RequestContext,
+ AgentExecutionEvent,
+} from '@a2a-js/sdk/server';
+import { v4 as uuidv4 } from 'uuid';
+import { logger } from '../utils/logger.js';
+
+export class InitCommand implements Command {
+ name = 'init';
+ description = 'Analyzes the project and creates a tailored GEMINI.md file';
+ requiresWorkspace = true;
+ streaming = true;
+
+ private handleMessageResult(
+ result: { content: string; messageType: 'info' | 'error' },
+ context: CommandContext,
+ eventBus: ExecutionEventBus,
+ taskId: string,
+ contextId: string,
+ ): CommandExecutionResponse {
+ const statusState = result.messageType === 'error' ? 'failed' : 'completed';
+ const eventType =
+ result.messageType === 'error'
+ ? CoderAgentEvent.StateChangeEvent
+ : CoderAgentEvent.TextContentEvent;
+
+ const event: AgentExecutionEvent = {
+ kind: 'status-update',
+ taskId,
+ contextId,
+ status: {
+ state: statusState,
+ message: {
+ kind: 'message',
+ role: 'agent',
+ parts: [{ kind: 'text', text: result.content }],
+ messageId: uuidv4(),
+ taskId,
+ contextId,
+ },
+ timestamp: new Date().toISOString(),
+ },
+ final: true,
+ metadata: {
+ coderAgent: { kind: eventType },
+ model: context.config.getModel(),
+ },
+ };
+
+ logger.info('[EventBus event]: ', event);
+ eventBus.publish(event);
+ return {
+ name: this.name,
+ data: result,
+ };
+ }
+
+ private async handleSubmitPromptResult(
+ result: { content: unknown },
+ context: CommandContext,
+ geminiMdPath: string,
+ eventBus: ExecutionEventBus,
+ taskId: string,
+ contextId: string,
+ ): Promise {
+ fs.writeFileSync(geminiMdPath, '', 'utf8');
+
+ if (!context.agentExecutor) {
+ throw new Error('Agent executor not found in context.');
+ }
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const agentExecutor = context.agentExecutor as CoderAgentExecutor;
+
+ const agentSettings: AgentSettings = {
+ kind: CoderAgentEvent.StateAgentSettingsEvent,
+ workspacePath: process.env['CODER_AGENT_WORKSPACE_PATH']!,
+ autoExecute: true,
+ };
+
+ if (typeof result.content !== 'string') {
+ throw new Error('Init command content must be a string.');
+ }
+ const promptText = result.content;
+
+ const requestContext: RequestContext = {
+ userMessage: {
+ kind: 'message',
+ role: 'user',
+ parts: [{ kind: 'text', text: promptText }],
+ messageId: uuidv4(),
+ taskId,
+ contextId,
+ metadata: {
+ coderAgent: agentSettings,
+ },
+ },
+ taskId,
+ contextId,
+ };
+
+ // The executor will handle the entire agentic loop, including
+ // creating the task, streaming responses, and handling tools.
+ await agentExecutor.execute(requestContext, eventBus);
+ return {
+ name: this.name,
+ data: geminiMdPath,
+ };
+ }
+
+ async execute(
+ context: CommandContext,
+ _args: string[] = [],
+ ): Promise {
+ if (!context.eventBus) {
+ return {
+ name: this.name,
+ data: 'Use executeStream to get streaming results.',
+ };
+ }
+
+ const geminiMdPath = path.join(
+ process.env['CODER_AGENT_WORKSPACE_PATH']!,
+ 'GEMINI.md',
+ );
+ const result = performInit(fs.existsSync(geminiMdPath));
+
+ const taskId = uuidv4();
+ const contextId = uuidv4();
+
+ switch (result.type) {
+ case 'message':
+ return this.handleMessageResult(
+ result,
+ context,
+ context.eventBus,
+ taskId,
+ contextId,
+ );
+ case 'submit_prompt':
+ return this.handleSubmitPromptResult(
+ result,
+ context,
+ geminiMdPath,
+ context.eventBus,
+ taskId,
+ contextId,
+ );
+ default:
+ throw new Error('Unknown result type from performInit');
+ }
+ }
+}
diff --git a/packages/a2a-server/src/commands/memory.test.ts b/packages/a2a-server/src/commands/memory.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0edcf8ef436b96a1e040c6253336110b7846631c
--- /dev/null
+++ b/packages/a2a-server/src/commands/memory.test.ts
@@ -0,0 +1,114 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ listMemoryFiles,
+ refreshMemory,
+ showMemory,
+ type Config,
+} from '@google/gemini-cli-core';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ ListMemoryCommand,
+ MemoryCommand,
+ RefreshMemoryCommand,
+ ShowMemoryCommand,
+} from './memory.js';
+import type { CommandContext } from './types.js';
+
+// Mock the core functions
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ showMemory: vi.fn(),
+ refreshMemory: vi.fn(),
+ listMemoryFiles: vi.fn(),
+ };
+});
+
+const mockShowMemory = vi.mocked(showMemory);
+const mockRefreshMemory = vi.mocked(refreshMemory);
+const mockListMemoryFiles = vi.mocked(listMemoryFiles);
+
+describe('a2a-server memory commands', () => {
+ let mockContext: CommandContext;
+ let mockConfig: Config;
+
+ beforeEach(() => {
+ mockConfig = {} as unknown as Config;
+
+ mockContext = {
+ config: mockConfig,
+ };
+ });
+
+ describe('MemoryCommand', () => {
+ it('delegates to ShowMemoryCommand', async () => {
+ const command = new MemoryCommand();
+ mockShowMemory.mockReturnValue({
+ type: 'message',
+ messageType: 'info',
+ content: 'showing memory',
+ });
+ const response = await command.execute(mockContext, []);
+ expect(response.data).toBe('showing memory');
+ expect(mockShowMemory).toHaveBeenCalledWith(mockContext.config);
+ });
+ });
+
+ describe('ShowMemoryCommand', () => {
+ it('executes showMemory and returns the content', async () => {
+ const command = new ShowMemoryCommand();
+ mockShowMemory.mockReturnValue({
+ type: 'message',
+ messageType: 'info',
+ content: 'test memory content',
+ });
+
+ const response = await command.execute(mockContext, []);
+
+ expect(mockShowMemory).toHaveBeenCalledWith(mockContext.config);
+ expect(response.name).toBe('memory show');
+ expect(response.data).toBe('test memory content');
+ });
+ });
+
+ describe('RefreshMemoryCommand', () => {
+ it('executes refreshMemory and returns the content', async () => {
+ const command = new RefreshMemoryCommand();
+ mockRefreshMemory.mockResolvedValue({
+ type: 'message',
+ messageType: 'info',
+ content: 'memory refreshed',
+ });
+
+ const response = await command.execute(mockContext, []);
+
+ expect(mockRefreshMemory).toHaveBeenCalledWith(mockContext.config);
+ expect(response.name).toBe('memory refresh');
+ expect(response.data).toBe('memory refreshed');
+ });
+ });
+
+ describe('ListMemoryCommand', () => {
+ it('executes listMemoryFiles and returns the content', async () => {
+ const command = new ListMemoryCommand();
+ mockListMemoryFiles.mockReturnValue({
+ type: 'message',
+ messageType: 'info',
+ content: 'file1.md\nfile2.md',
+ });
+
+ const response = await command.execute(mockContext, []);
+
+ expect(mockListMemoryFiles).toHaveBeenCalledWith(mockContext.config);
+ expect(response.name).toBe('memory list');
+ expect(response.data).toBe('file1.md\nfile2.md');
+ });
+ });
+});
diff --git a/packages/a2a-server/src/commands/memory.ts b/packages/a2a-server/src/commands/memory.ts
new file mode 100644
index 0000000000000000000000000000000000000000..628669a7e6815b6697f7d8ad5e41d49cc53e9b6c
--- /dev/null
+++ b/packages/a2a-server/src/commands/memory.ts
@@ -0,0 +1,74 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ listMemoryFiles,
+ refreshMemory,
+ showMemory,
+} from '@google/gemini-cli-core';
+import type {
+ Command,
+ CommandContext,
+ CommandExecutionResponse,
+} from './types.js';
+
+export class MemoryCommand implements Command {
+ readonly name = 'memory';
+ readonly description = 'Manage memory.';
+ readonly subCommands = [
+ new ShowMemoryCommand(),
+ new RefreshMemoryCommand(),
+ new ListMemoryCommand(),
+ ];
+ readonly topLevel = true;
+ readonly requiresWorkspace = true;
+
+ async execute(
+ context: CommandContext,
+ _: string[],
+ ): Promise {
+ return new ShowMemoryCommand().execute(context, _);
+ }
+}
+
+export class ShowMemoryCommand implements Command {
+ readonly name = 'memory show';
+ readonly description = 'Shows the current memory contents.';
+
+ async execute(
+ context: CommandContext,
+ _: string[],
+ ): Promise {
+ const result = showMemory(context.config);
+ return { name: this.name, data: result.content };
+ }
+}
+
+export class RefreshMemoryCommand implements Command {
+ readonly name = 'memory refresh';
+ readonly description = 'Refreshes the memory from the source.';
+
+ async execute(
+ context: CommandContext,
+ _: string[],
+ ): Promise {
+ const result = await refreshMemory(context.config);
+ return { name: this.name, data: result.content };
+ }
+}
+
+export class ListMemoryCommand implements Command {
+ readonly name = 'memory list';
+ readonly description = 'Lists the paths of the GEMINI.md files in use.';
+
+ async execute(
+ context: CommandContext,
+ _: string[],
+ ): Promise {
+ const result = listMemoryFiles(context.config);
+ return { name: this.name, data: result.content };
+ }
+}
diff --git a/packages/a2a-server/src/commands/restore.test.ts b/packages/a2a-server/src/commands/restore.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a655f36a744d2b62456c7df1d79ceca0d0d49da4
--- /dev/null
+++ b/packages/a2a-server/src/commands/restore.test.ts
@@ -0,0 +1,137 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { RestoreCommand, ListCheckpointsCommand } from './restore.js';
+import type { CommandContext } from './types.js';
+import type { Config } from '@google/gemini-cli-core';
+import { createMockConfig } from '../utils/testing_utils.js';
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+const mockPerformRestore = vi.hoisted(() => vi.fn());
+const mockLoggerInfo = vi.hoisted(() => vi.fn());
+const mockGetCheckpointInfoList = vi.hoisted(() => vi.fn());
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const original =
+ await importOriginal();
+ return {
+ ...original,
+ performRestore: mockPerformRestore,
+ getCheckpointInfoList: mockGetCheckpointInfoList,
+ };
+});
+
+const mockFs = vi.hoisted(() => ({
+ readFile: vi.fn(),
+ readdir: vi.fn(),
+ mkdir: vi.fn(),
+}));
+
+vi.mock('node:fs/promises', () => mockFs);
+
+vi.mock('../utils/logger.js', () => ({
+ logger: {
+ info: mockLoggerInfo,
+ },
+}));
+
+describe('RestoreCommand', () => {
+ const mockConfig = {
+ config: createMockConfig() as Config,
+ git: {},
+ } as CommandContext;
+
+ it('should return error if no checkpoint name is provided', async () => {
+ const command = new RestoreCommand();
+ const result = await command.execute(mockConfig, []);
+ expect(result.data).toEqual({
+ type: 'message',
+ messageType: 'error',
+ content: 'Please provide a checkpoint name to restore.',
+ });
+ });
+
+ it('should restore a checkpoint when a valid file is provided', async () => {
+ const command = new RestoreCommand();
+ const toolCallData = {
+ toolCall: {
+ name: 'test-tool',
+ args: {},
+ },
+ history: [],
+ clientHistory: [],
+ commitHash: '123',
+ };
+ mockFs.readFile.mockResolvedValue(JSON.stringify(toolCallData));
+ const restoreContent = {
+ type: 'message',
+ messageType: 'info',
+ content: 'Restored',
+ };
+ mockPerformRestore.mockReturnValue(
+ (async function* () {
+ yield restoreContent;
+ })(),
+ );
+ const result = await command.execute(mockConfig, ['checkpoint1.json']);
+ expect(result.data).toEqual([restoreContent]);
+ });
+
+ it('should show "file not found" error for a non-existent checkpoint', async () => {
+ const command = new RestoreCommand();
+ const error = new Error('File not found');
+ (error as NodeJS.ErrnoException).code = 'ENOENT';
+ mockFs.readFile.mockRejectedValue(error);
+ const result = await command.execute(mockConfig, ['checkpoint2.json']);
+ expect(result.data).toEqual({
+ type: 'message',
+ messageType: 'error',
+ content: 'File not found: checkpoint2.json',
+ });
+ });
+
+ it('should handle invalid JSON in checkpoint file', async () => {
+ const command = new RestoreCommand();
+ mockFs.readFile.mockResolvedValue('invalid json');
+ const result = await command.execute(mockConfig, ['checkpoint1.json']);
+ expect((result.data as { content: string }).content).toContain(
+ 'An unexpected error occurred during restore.',
+ );
+ });
+});
+
+describe('ListCheckpointsCommand', () => {
+ const mockConfig = {
+ config: createMockConfig() as Config,
+ } as CommandContext;
+
+ it('should list all available checkpoints', async () => {
+ const command = new ListCheckpointsCommand();
+ const checkpointInfo = [{ file: 'checkpoint1.json', description: 'Test' }];
+ mockFs.readdir.mockResolvedValue(['checkpoint1.json']);
+ mockFs.readFile.mockResolvedValue(
+ JSON.stringify({ toolCall: { name: 'Test', args: {} } }),
+ );
+ mockGetCheckpointInfoList.mockReturnValue(checkpointInfo);
+ const result = await command.execute(mockConfig);
+ expect((result.data as { content: string }).content).toEqual(
+ JSON.stringify(checkpointInfo),
+ );
+ });
+
+ it('should handle errors when listing checkpoints', async () => {
+ const command = new ListCheckpointsCommand();
+ mockFs.readdir.mockRejectedValue(new Error('Read error'));
+ const result = await command.execute(mockConfig);
+ expect((result.data as { content: string }).content).toContain(
+ 'An unexpected error occurred while listing checkpoints.',
+ );
+ });
+});
diff --git a/packages/a2a-server/src/commands/restore.ts b/packages/a2a-server/src/commands/restore.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7a5205c66bf5bcd1732c3c7db0ca25a4b4d33afe
--- /dev/null
+++ b/packages/a2a-server/src/commands/restore.ts
@@ -0,0 +1,156 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ getCheckpointInfoList,
+ getToolCallDataSchema,
+ isNodeError,
+ performRestore,
+} from '@google/gemini-cli-core';
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import type {
+ Command,
+ CommandContext,
+ CommandExecutionResponse,
+} from './types.js';
+
+export class RestoreCommand implements Command {
+ readonly name = 'restore';
+ readonly description =
+ 'Restore to a previous checkpoint, or list available checkpoints to restore. This will reset the conversation and file history to the state it was in when the checkpoint was created';
+ readonly topLevel = true;
+ readonly requiresWorkspace = true;
+ readonly subCommands = [new ListCheckpointsCommand()];
+
+ async execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise {
+ const { config, git: gitService } = context;
+ const argsStr = args.join(' ');
+
+ try {
+ if (!argsStr) {
+ return {
+ name: this.name,
+ data: {
+ type: 'message',
+ messageType: 'error',
+ content: 'Please provide a checkpoint name to restore.',
+ },
+ };
+ }
+
+ const selectedFile = argsStr.endsWith('.json')
+ ? argsStr
+ : `${argsStr}.json`;
+
+ const checkpointDir = config.storage.getProjectTempCheckpointsDir();
+ const filePath = path.join(checkpointDir, selectedFile);
+
+ let data: string;
+ try {
+ data = await fs.readFile(filePath, 'utf-8');
+ } catch (error) {
+ if (isNodeError(error) && error.code === 'ENOENT') {
+ return {
+ name: this.name,
+ data: {
+ type: 'message',
+ messageType: 'error',
+ content: `File not found: ${selectedFile}`,
+ },
+ };
+ }
+ throw error;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const toolCallData = JSON.parse(data);
+ const ToolCallDataSchema = getToolCallDataSchema();
+ const parseResult = ToolCallDataSchema.safeParse(toolCallData);
+
+ if (!parseResult.success) {
+ return {
+ name: this.name,
+ data: {
+ type: 'message',
+ messageType: 'error',
+ content: 'Checkpoint file is invalid or corrupted.',
+ },
+ };
+ }
+
+ const restoreResultGenerator = performRestore(
+ parseResult.data,
+ gitService,
+ );
+ const restoreResult = [];
+ for await (const result of restoreResultGenerator) {
+ restoreResult.push(result);
+ }
+
+ return {
+ name: this.name,
+ data: restoreResult,
+ };
+ } catch {
+ return {
+ name: this.name,
+ data: {
+ type: 'message',
+ messageType: 'error',
+ content: 'An unexpected error occurred during restore.',
+ },
+ };
+ }
+ }
+}
+
+export class ListCheckpointsCommand implements Command {
+ readonly name = 'restore list';
+ readonly description = 'Lists all available checkpoints.';
+ readonly topLevel = false;
+
+ async execute(context: CommandContext): Promise {
+ const { config } = context;
+
+ try {
+ const checkpointDir = config.storage.getProjectTempCheckpointsDir();
+ await fs.mkdir(checkpointDir, { recursive: true });
+ const files = await fs.readdir(checkpointDir);
+ const jsonFiles = files.filter((file) => file.endsWith('.json'));
+
+ const checkpointFiles = new Map();
+ for (const file of jsonFiles) {
+ const filePath = path.join(checkpointDir, file);
+ const data = await fs.readFile(filePath, 'utf-8');
+ checkpointFiles.set(file, data);
+ }
+
+ const checkpointInfoList = getCheckpointInfoList(checkpointFiles);
+
+ return {
+ name: this.name,
+ data: {
+ type: 'message',
+ messageType: 'info',
+ content: JSON.stringify(checkpointInfoList),
+ },
+ };
+ } catch {
+ return {
+ name: this.name,
+ data: {
+ type: 'message',
+ messageType: 'error',
+ content: 'An unexpected error occurred while listing checkpoints.',
+ },
+ };
+ }
+ }
+}
diff --git a/packages/a2a-server/src/commands/types.ts b/packages/a2a-server/src/commands/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..910515eadce9be6eae21da7c084680adde0d8aaa
--- /dev/null
+++ b/packages/a2a-server/src/commands/types.ts
@@ -0,0 +1,41 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { ExecutionEventBus, AgentExecutor } from '@a2a-js/sdk/server';
+import type { Config, GitService } from '@google/gemini-cli-core';
+
+export interface CommandContext {
+ config: Config;
+ git?: GitService;
+ agentExecutor?: AgentExecutor;
+ eventBus?: ExecutionEventBus;
+}
+
+export interface CommandArgument {
+ readonly name: string;
+ readonly description: string;
+ readonly isRequired?: boolean;
+}
+
+export interface Command {
+ readonly name: string;
+ readonly description: string;
+ readonly arguments?: CommandArgument[];
+ readonly subCommands?: Command[];
+ readonly topLevel?: boolean;
+ readonly requiresWorkspace?: boolean;
+ readonly streaming?: boolean;
+
+ execute(
+ config: CommandContext,
+ args: string[],
+ ): Promise;
+}
+
+export interface CommandExecutionResponse {
+ readonly name: string;
+ readonly data: unknown;
+}
diff --git a/packages/a2a-server/src/config/config.test.ts b/packages/a2a-server/src/config/config.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b226e9895c21c35cd8a181b4c894ef58ee636cd8
--- /dev/null
+++ b/packages/a2a-server/src/config/config.test.ts
@@ -0,0 +1,585 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import * as path from 'node:path';
+import { loadConfig } from './config.js';
+import type { Settings } from './settings.js';
+import {
+ type ExtensionLoader,
+ getCodeAssistServer,
+ Config,
+ ExperimentFlags,
+ fetchAdminControlsOnce,
+ type FetchAdminControlsResponse,
+ AuthType,
+ isHeadlessMode,
+ FatalAuthenticationError,
+ PolicyDecision,
+ ApprovalMode,
+ PRIORITY_YOLO_ALLOW_ALL,
+ createPolicyEngineConfig,
+} from '@google/gemini-cli-core';
+import type { AgentSettings } from '../types.js';
+
+// Mock dependencies
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ PRIORITY_YOLO_ALLOW_ALL: 998,
+ Config: vi.fn().mockImplementation((params) => {
+ const mockConfig = {
+ ...params,
+ initialize: vi.fn(),
+ waitForMcpInit: vi.fn(),
+ refreshAuth: vi.fn(),
+ getExperiments: vi.fn().mockReturnValue({
+ flags: {
+ [actual.ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
+ boolValue: false,
+ },
+ },
+ }),
+ getRemoteAdminSettings: vi.fn(),
+ setRemoteAdminSettings: vi.fn(),
+ };
+ return mockConfig;
+ }),
+ startupProfiler: {
+ flush: vi.fn(),
+ },
+ isHeadlessMode: vi.fn().mockReturnValue(false),
+ getCodeAssistServer: vi.fn(),
+ fetchAdminControlsOnce: vi.fn(),
+ createPolicyEngineConfig: vi
+ .fn()
+ .mockImplementation(
+ (_settings, mode, _defaultPoliciesDir, _interactive) => ({
+ rules:
+ mode === actual.ApprovalMode.YOLO
+ ? [
+ {
+ toolName: '*',
+ decision: actual.PolicyDecision.ALLOW,
+ priority: actual.PRIORITY_YOLO_ALLOW_ALL,
+ modes: [actual.ApprovalMode.YOLO],
+ allowRedirection: true,
+ },
+ ]
+ : [
+ {
+ toolName: 'read_file',
+ decision: actual.PolicyDecision.ALLOW,
+ priority: 1.05,
+ source: 'Default: read-only.toml',
+ },
+ ],
+ checkers: [],
+ }),
+ ),
+ coreEvents: {
+ emitAdminSettingsChanged: vi.fn(),
+ },
+ };
+});
+
+vi.mock('../utils/logger.js', () => ({
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+describe('loadConfig', () => {
+ const mockSettings = {} as Settings;
+ const mockExtensionLoader = {} as ExtensionLoader;
+ const taskId = 'test-task-id';
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubEnv('GEMINI_API_KEY', 'test-key');
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ describe('admin settings overrides', () => {
+ it('should not fetch admin controls if experiment is disabled', async () => {
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+ expect(fetchAdminControlsOnce).not.toHaveBeenCalled();
+ });
+
+ it('should pass clientName as a2a-server to Config', async () => {
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ clientName: 'a2a-server',
+ }),
+ );
+ });
+
+ describe('when admin controls experiment is enabled', () => {
+ beforeEach(() => {
+ // We need to cast to any here to modify the mock implementation
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (Config as any).mockImplementation((params: unknown) => {
+ const mockConfig = {
+ ...(params as object),
+ initialize: vi.fn(),
+ waitForMcpInit: vi.fn(),
+ refreshAuth: vi.fn(),
+ getExperiments: vi.fn().mockReturnValue({
+ flags: {
+ [ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
+ boolValue: true,
+ },
+ },
+ }),
+ getRemoteAdminSettings: vi.fn().mockReturnValue({}),
+ setRemoteAdminSettings: vi.fn(),
+ };
+ return mockConfig;
+ });
+ });
+
+ it('should fetch admin controls and apply them', async () => {
+ const mockAdminSettings: FetchAdminControlsResponse = {
+ mcpSetting: {
+ mcpEnabled: false,
+ },
+ cliFeatureSetting: {
+ extensionsSetting: {
+ extensionsEnabled: false,
+ },
+ },
+ strictModeDisabled: false,
+ };
+ vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
+
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+
+ expect(Config).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ disableYoloMode: !mockAdminSettings.strictModeDisabled,
+ mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
+ extensionsEnabled:
+ mockAdminSettings.cliFeatureSetting?.extensionsSetting
+ ?.extensionsEnabled,
+ }),
+ );
+ });
+
+ it('should treat unset admin settings as false when admin settings are passed', async () => {
+ const mockAdminSettings: FetchAdminControlsResponse = {
+ mcpSetting: {
+ mcpEnabled: true,
+ },
+ };
+ vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
+
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+
+ expect(Config).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ disableYoloMode: !false,
+ mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
+ extensionsEnabled: undefined,
+ }),
+ );
+ });
+
+ it('should not pass default unset admin settings when no admin settings are present', async () => {
+ const mockAdminSettings: FetchAdminControlsResponse = {};
+ vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
+
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+
+ expect(Config).toHaveBeenLastCalledWith(expect.objectContaining({}));
+ });
+
+ it('should fetch admin controls using the code assist server when available', async () => {
+ const mockAdminSettings: FetchAdminControlsResponse = {
+ mcpSetting: {
+ mcpEnabled: true,
+ },
+ strictModeDisabled: true,
+ };
+ const mockCodeAssistServer = { projectId: 'test-project' };
+ vi.mocked(getCodeAssistServer).mockReturnValue(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ mockCodeAssistServer as any,
+ );
+ vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
+
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+
+ expect(fetchAdminControlsOnce).toHaveBeenCalledWith(
+ mockCodeAssistServer,
+ true,
+ );
+ expect(Config).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ disableYoloMode: !mockAdminSettings.strictModeDisabled,
+ mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
+ extensionsEnabled: undefined,
+ }),
+ );
+ });
+ });
+ });
+
+ it('should set customIgnoreFilePaths when CUSTOM_IGNORE_FILE_PATHS env var is present', async () => {
+ const testPath = '/tmp/ignore';
+ vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath);
+ const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
+ testPath,
+ ]);
+ });
+
+ it('should set customIgnoreFilePaths when settings.fileFiltering.customIgnoreFilePaths is present', async () => {
+ const testPath = '/settings/ignore';
+ const settings: Settings = {
+ fileFiltering: {
+ customIgnoreFilePaths: [testPath],
+ },
+ };
+ const config = await loadConfig(settings, mockExtensionLoader, taskId);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
+ testPath,
+ ]);
+ });
+
+ it('should merge customIgnoreFilePaths from settings and env var', async () => {
+ const envPath = '/env/ignore';
+ const settingsPath = '/settings/ignore';
+ vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', envPath);
+ const settings: Settings = {
+ fileFiltering: {
+ customIgnoreFilePaths: [settingsPath],
+ },
+ };
+ const config = await loadConfig(settings, mockExtensionLoader, taskId);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
+ settingsPath,
+ envPath,
+ ]);
+ });
+
+ it('should split CUSTOM_IGNORE_FILE_PATHS using system delimiter', async () => {
+ const paths = ['/path/one', '/path/two'];
+ vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', paths.join(path.delimiter));
+ const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual(paths);
+ });
+
+ it('should have empty customIgnoreFilePaths when both are missing', async () => {
+ const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([]);
+ });
+
+ describe('policy engine configuration', () => {
+ it('should map tool settings into policySettings', async () => {
+ const settings: Settings = {
+ tools: {
+ allowed: ['v2-allowed'],
+ exclude: ['v2-exclude'],
+ core: ['v2-core'],
+ },
+ mcpServers: {
+ test: { command: 'test', args: [] },
+ },
+ policyPaths: ['/path/to/policy'],
+ adminPolicyPaths: ['/path/to/admin/policy'],
+ };
+
+ await loadConfig(settings, mockExtensionLoader, taskId, true);
+
+ expect(createPolicyEngineConfig).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tools: {
+ core: ['v2-core'],
+ exclude: ['v2-exclude'],
+ allowed: ['v2-allowed'],
+ },
+ mcpServers: settings.mcpServers,
+ policyPaths: settings.policyPaths,
+ adminPolicyPaths: settings.adminPolicyPaths,
+ }),
+ ApprovalMode.DEFAULT,
+ undefined,
+ true,
+ );
+ });
+ });
+
+ describe('tool configuration', () => {
+ it('should pass V2 tools.allowed to Config properly', async () => {
+ const settings: Settings = {
+ tools: {
+ allowed: ['shell', 'fetch'],
+ },
+ };
+ await loadConfig(settings, mockExtensionLoader, taskId, true);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ allowedTools: ['shell', 'fetch'],
+ }),
+ );
+ });
+
+ it('should pass enableAgents to Config constructor', async () => {
+ const settings: Settings = {
+ experimental: {
+ enableAgents: false,
+ },
+ };
+ await loadConfig(settings, mockExtensionLoader, taskId);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ enableAgents: false,
+ }),
+ );
+ });
+
+ it('should default enableAgents to true when not provided', async () => {
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ enableAgents: true,
+ }),
+ );
+ });
+
+ describe('interactivity', () => {
+ it('should always set interactive true', async () => {
+ vi.mocked(isHeadlessMode).mockReturnValue(true);
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ interactive: true,
+ }),
+ );
+
+ vi.mocked(isHeadlessMode).mockReturnValue(false);
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ interactive: true,
+ }),
+ );
+ });
+
+ it('should set enableInteractiveShell based on headless mode', async () => {
+ vi.mocked(isHeadlessMode).mockReturnValue(false);
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ enableInteractiveShell: true,
+ }),
+ );
+
+ vi.mocked(isHeadlessMode).mockReturnValue(true);
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ enableInteractiveShell: false,
+ }),
+ );
+ });
+ });
+
+ describe('YOLO mode', () => {
+ it('should enable YOLO mode and add policy rule when GEMINI_YOLO_MODE is true', async () => {
+ vi.stubEnv('GEMINI_YOLO_MODE', 'true');
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ approvalMode: 'yolo',
+ policyEngineConfig: expect.objectContaining({
+ rules: expect.arrayContaining([
+ expect.objectContaining({
+ decision: PolicyDecision.ALLOW,
+ priority: PRIORITY_YOLO_ALLOW_ALL,
+ modes: ['yolo'],
+ allowRedirection: true,
+ }),
+ ]),
+ }),
+ }),
+ );
+ });
+
+ it('should use default approval mode and load default rules when GEMINI_YOLO_MODE is not true', async () => {
+ vi.stubEnv('GEMINI_YOLO_MODE', 'false');
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ approvalMode: 'default',
+ policyEngineConfig: expect.objectContaining({
+ rules: expect.arrayContaining([
+ expect.objectContaining({
+ toolName: 'read_file',
+ decision: PolicyDecision.ALLOW,
+ }),
+ ]),
+ }),
+ }),
+ );
+ });
+ });
+
+ describe('authentication logic', () => {
+ const setupConfigMock = (refreshAuthMock: ReturnType) => {
+ vi.mocked(Config).mockImplementation(
+ (params: unknown) =>
+ ({
+ ...(params as object),
+ initialize: vi.fn(),
+ waitForMcpInit: vi.fn(),
+ refreshAuth: refreshAuthMock,
+ getExperiments: vi.fn().mockReturnValue({ flags: {} }),
+ getRemoteAdminSettings: vi.fn(),
+ setRemoteAdminSettings: vi.fn(),
+ }) as unknown as Config,
+ );
+ };
+
+ beforeEach(() => {
+ vi.stubEnv('USE_CCPA', 'true');
+ vi.stubEnv('GEMINI_API_KEY', '');
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('should attempt COMPUTE_ADC by default and bypass LOGIN_WITH_GOOGLE if successful', async () => {
+ const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
+ setupConfigMock(refreshAuthMock);
+
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+
+ expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
+ expect(refreshAuthMock).not.toHaveBeenCalledWith(
+ AuthType.LOGIN_WITH_GOOGLE,
+ );
+ });
+
+ it('should fallback to LOGIN_WITH_GOOGLE if COMPUTE_ADC fails and interactive mode is available', async () => {
+ vi.mocked(isHeadlessMode).mockReturnValue(false);
+ const refreshAuthMock = vi.fn().mockImplementation((authType) => {
+ if (authType === AuthType.COMPUTE_ADC) {
+ return Promise.reject(new Error('ADC failed'));
+ }
+ return Promise.resolve();
+ });
+ setupConfigMock(refreshAuthMock);
+
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+
+ expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
+ expect(refreshAuthMock).toHaveBeenCalledWith(
+ AuthType.LOGIN_WITH_GOOGLE,
+ );
+ });
+
+ it('should throw FatalAuthenticationError in headless mode if COMPUTE_ADC fails', async () => {
+ vi.mocked(isHeadlessMode).mockReturnValue(true);
+
+ const refreshAuthMock = vi.fn().mockImplementation((authType) => {
+ if (authType === AuthType.COMPUTE_ADC) {
+ return Promise.reject(new Error('ADC not found'));
+ }
+ return Promise.resolve();
+ });
+ setupConfigMock(refreshAuthMock);
+
+ await expect(
+ loadConfig(mockSettings, mockExtensionLoader, taskId),
+ ).rejects.toThrow(
+ 'COMPUTE_ADC failed: ADC not found. (LOGIN_WITH_GOOGLE fallback skipped due to headless mode. Run in an interactive terminal to use OAuth.)',
+ );
+
+ expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
+ expect(refreshAuthMock).not.toHaveBeenCalledWith(
+ AuthType.LOGIN_WITH_GOOGLE,
+ );
+ });
+
+ it('should include both original and fallback error when LOGIN_WITH_GOOGLE fallback fails', async () => {
+ vi.mocked(isHeadlessMode).mockReturnValue(false);
+
+ const refreshAuthMock = vi.fn().mockImplementation((authType) => {
+ if (authType === AuthType.COMPUTE_ADC) {
+ throw new Error('ADC failed');
+ }
+ if (authType === AuthType.LOGIN_WITH_GOOGLE) {
+ throw new FatalAuthenticationError('OAuth failed');
+ }
+ return Promise.resolve();
+ });
+ setupConfigMock(refreshAuthMock);
+
+ await expect(
+ loadConfig(mockSettings, mockExtensionLoader, taskId),
+ ).rejects.toThrow(
+ 'OAuth failed. The initial COMPUTE_ADC attempt also failed: ADC failed',
+ );
+ });
+ });
+ });
+});
+
+describe('setIsTrusted', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ // Ensure GEMINI_CLI_TRUST_WORKSPACE is not set by default in tests to prevent leakage
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', '');
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('should return agentSettings.isTrusted if defined, ignoring env vars', async () => {
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'false');
+ const { setIsTrusted } = await import('./config.js');
+ expect(setIsTrusted({ isTrusted: true } as AgentSettings)).toBe(true);
+
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
+ expect(setIsTrusted({ isTrusted: false } as AgentSettings)).toBe(false);
+ });
+
+ it('should return true when GEMINI_CLI_TRUST_WORKSPACE env var is true and agentSettings.isTrusted is undefined', async () => {
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
+ const { setIsTrusted } = await import('./config.js');
+ expect(setIsTrusted(undefined)).toBe(true);
+ expect(setIsTrusted({} as AgentSettings)).toBe(true);
+ });
+
+ it('should return false when GEMINI_CLI_TRUST_WORKSPACE env var is false and agentSettings.isTrusted is undefined', async () => {
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'false');
+ const { setIsTrusted } = await import('./config.js');
+ expect(setIsTrusted(undefined)).toBe(false);
+ expect(setIsTrusted({} as AgentSettings)).toBe(false);
+ });
+
+ it('should fallback to false if agentSettings.isTrusted and env var are undefined and no workspaceRoot is provided', async () => {
+ const { setIsTrusted } = await import('./config.js');
+ expect(setIsTrusted(undefined)).toBe(false);
+ expect(setIsTrusted({} as AgentSettings)).toBe(false);
+ });
+});
diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c08aaac2737722f3ba0bc91723ffda4359d5ba8a
--- /dev/null
+++ b/packages/a2a-server/src/config/config.ts
@@ -0,0 +1,708 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as dotenv from 'dotenv';
+import { AsyncLocalStorage } from 'node:async_hooks';
+
+import {
+ AuthType,
+ Config,
+ ApprovalMode,
+ GEMINI_DIR,
+ DEFAULT_GEMINI_EMBEDDING_MODEL,
+ startupProfiler,
+ PREVIEW_GEMINI_MODEL,
+ homedir,
+ tmpdir,
+ GitService,
+ fetchAdminControlsOnce,
+ getCodeAssistServer,
+ ExperimentFlags,
+ isHeadlessMode,
+ FatalAuthenticationError,
+ createPolicyEngineConfig,
+ type PolicySettings,
+ type TelemetryTarget,
+ type ConfigParameters,
+ type ExtensionLoader,
+ resolveToRealPath,
+ checkPathTrust,
+} from '@google/gemini-cli-core';
+
+import { logger } from '../utils/logger.js';
+import { type Settings, loadSettings } from './settings.js';
+import { type AgentSettings, CoderAgentEvent } from '../types.js';
+
+export const envStorage = new AsyncLocalStorage();
+
+const deletedKeysSymbol = Symbol('deletedKeys');
+export const cwdSymbol = Symbol('cwd');
+
+export interface TaskEnv extends Record {
+ [deletedKeysSymbol]?: Set;
+ [cwdSymbol]?: string;
+}
+
+// Set up a Proxy on process.env to intercept reads and writes, isolating environment variables per task
+const originalEnv = process.env;
+const envProxy = new Proxy(originalEnv, {
+ get(target, prop) {
+ if (typeof prop === 'string') {
+ const taskEnv = envStorage.getStore();
+ if (taskEnv) {
+ const deleted = taskEnv[deletedKeysSymbol];
+ if (deleted?.has(prop)) {
+ return undefined;
+ }
+ if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
+ return taskEnv[prop];
+ }
+ }
+ return target[prop];
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
+ return target[prop as any];
+ },
+ has(target, prop) {
+ if (typeof prop === 'string') {
+ const taskEnv = envStorage.getStore();
+ if (taskEnv) {
+ const deleted = taskEnv[deletedKeysSymbol];
+ if (deleted?.has(prop)) {
+ return false;
+ }
+ if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
+ return true;
+ }
+ }
+ return prop in target;
+ }
+ return prop in target;
+ },
+ set(target, prop, value) {
+ if (typeof prop === 'string') {
+ if (
+ prop === '__proto__' ||
+ prop === 'constructor' ||
+ prop === 'prototype'
+ ) {
+ return false;
+ }
+ const taskEnv = envStorage.getStore();
+ if (taskEnv) {
+ taskEnv[deletedKeysSymbol]?.delete(prop);
+ taskEnv[prop] = String(value);
+ return true;
+ }
+ target[prop] = String(value);
+ return true;
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
+ target[prop as any] = value;
+ return true;
+ },
+ deleteProperty(target, prop) {
+ if (typeof prop === 'string') {
+ if (
+ prop === '__proto__' ||
+ prop === 'constructor' ||
+ prop === 'prototype'
+ ) {
+ return false;
+ }
+ const taskEnv = envStorage.getStore();
+ if (taskEnv) {
+ delete taskEnv[prop];
+ (taskEnv[deletedKeysSymbol] ??= new Set()).add(prop);
+ return true;
+ }
+ delete target[prop];
+ return true;
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
+ return delete target[prop as any];
+ },
+ ownKeys(target) {
+ const taskEnv = envStorage.getStore();
+ if (taskEnv) {
+ const keys = new Set([
+ ...Object.getOwnPropertyNames(target),
+ ...Object.getOwnPropertySymbols(target),
+ ...Object.keys(taskEnv),
+ ]);
+ taskEnv[deletedKeysSymbol]?.forEach((key) => {
+ keys.delete(key);
+ });
+ return Array.from(keys);
+ }
+ return [
+ ...Object.getOwnPropertyNames(target),
+ ...Object.getOwnPropertySymbols(target),
+ ];
+ },
+ getOwnPropertyDescriptor(target, prop) {
+ const taskEnv = envStorage.getStore();
+ if (taskEnv && typeof prop === 'string') {
+ const deleted = taskEnv[deletedKeysSymbol];
+ if (deleted?.has(prop)) {
+ return undefined;
+ }
+ if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
+ return {
+ value: taskEnv[prop],
+ writable: true,
+ enumerable: true,
+ configurable: true,
+ };
+ }
+ }
+ return Object.getOwnPropertyDescriptor(target, prop);
+ },
+ defineProperty(target, prop, descriptor) {
+ if (typeof prop === 'string') {
+ if (
+ prop === '__proto__' ||
+ prop === 'constructor' ||
+ prop === 'prototype'
+ ) {
+ return false;
+ }
+ const taskEnv = envStorage.getStore();
+ if (taskEnv) {
+ taskEnv[deletedKeysSymbol]?.delete(prop);
+ taskEnv[prop] =
+ descriptor.value !== undefined ? String(descriptor.value) : undefined;
+ return true;
+ }
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
+ Object.defineProperty(target, prop as any, descriptor);
+ return true;
+ },
+});
+
+Object.defineProperty(process, 'env', {
+ value: envProxy,
+ writable: false,
+ configurable: true,
+});
+
+// NOTE: Monkey-patching process.cwd and process.chdir via AsyncLocalStorage is a robust way
+// to simulate workspace isolation in a concurrent server. However, please be aware of a critical
+// limitation: Node.js native C++ APIs (such as fs.readFileSync, fs.writeFile, etc.) and child
+// process spawning APIs (like child_process.spawn) resolve relative paths using the OS-level
+// working directory of the process, NOT the JS-level process.cwd() function.
+// To prevent cross-task interference, all file paths in the core package must be resolved to
+// absolute paths using path.resolve/path.join relative to config.getTargetDir() or config.getCwd()
+// before being passed to native APIs.
+const originalCwd = process.cwd;
+process.cwd = function () {
+ const taskEnv = envStorage.getStore();
+ if (taskEnv && taskEnv[cwdSymbol]) {
+ return taskEnv[cwdSymbol];
+ }
+ return originalCwd.call(process);
+};
+
+const originalChdir = process.chdir;
+process.chdir = function (directory: string) {
+ const taskEnv = envStorage.getStore();
+ if (taskEnv) {
+ const resolved = path.resolve(process.cwd(), directory);
+ try {
+ const stats = fs.statSync(resolved);
+ if (!stats.isDirectory()) {
+ const err = new Error(
+ "ENOTDIR: not a directory, chdir '" + resolved + "'",
+ );
+ (err as NodeJS.ErrnoException).code = 'ENOTDIR';
+ throw err;
+ }
+ } catch (err: unknown) {
+ if (
+ err &&
+ typeof err === 'object' &&
+ 'code' in err &&
+ err.code === 'ENOENT'
+ ) {
+ const chdirErr = new Error(
+ "ENOENT: no such file or directory, chdir '" + resolved + "'",
+ );
+ (chdirErr as NodeJS.ErrnoException).code = 'ENOENT';
+ throw chdirErr;
+ }
+ throw err;
+ }
+ taskEnv[cwdSymbol] = resolved;
+ return;
+ }
+ return originalChdir.call(process, directory);
+};
+
+export function getEnv(key: string): string | undefined {
+ return process.env[key];
+}
+
+export async function loadConfig(
+ settings: Settings,
+ extensionLoader: ExtensionLoader,
+ taskId: string,
+ trusted: boolean = false,
+ workspaceDir: string = process.cwd(),
+): Promise {
+ const workspaceEnv = await loadEnvironment(trusted, workspaceDir);
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const envVars: Record = { ...process.env } as Record<
+ string,
+ string
+ >;
+ Object.assign(envVars, workspaceEnv);
+
+ const getEnvLocal = (key: string) => envVars[key];
+
+ const folderTrust =
+ settings.folderTrust === true ||
+ getEnvLocal('GEMINI_FOLDER_TRUST') === 'true';
+
+ let checkpointing = getEnvLocal('CHECKPOINTING')
+ ? getEnvLocal('CHECKPOINTING') === 'true'
+ : settings.checkpointing?.enabled;
+
+ if (checkpointing) {
+ if (!(await GitService.verifyGitAvailability())) {
+ logger.warn(
+ '[Config] Checkpointing is enabled but git is not installed. Disabling checkpointing.',
+ );
+ checkpointing = false;
+ }
+ }
+
+ const approvalMode =
+ getEnvLocal('GEMINI_YOLO_MODE') === 'true'
+ ? ApprovalMode.YOLO
+ : ApprovalMode.DEFAULT;
+
+ if (!trusted) {
+ if (settings.mcpServers) {
+ logger.warn(
+ '[Configuration] Untrusted workspace detected. Stripping repository mcpServers definitions to prevent unintended command execution.',
+ );
+ }
+ if (settings.policyPaths) {
+ logger.warn(
+ '[Configuration] Untrusted workspace detected. Stripping repository policyPaths definitions to prevent unintended policy override.',
+ );
+ }
+ if (settings.adminPolicyPaths) {
+ logger.warn(
+ '[Configuration] Untrusted workspace detected. Stripping repository adminPolicyPaths definitions to prevent unintended admin policy override.',
+ );
+ }
+ if (settings.tools) {
+ logger.warn(
+ '[Configuration] Untrusted workspace detected. Stripping repository tools definitions to prevent unintended tool enablement.',
+ );
+ }
+ if (settings.telemetry) {
+ logger.warn(
+ '[Configuration] Untrusted workspace detected. Stripping repository telemetry definitions to prevent unintended data routing.',
+ );
+ }
+ settings = {
+ ...settings,
+ mcpServers: undefined,
+ policyPaths: undefined,
+ adminPolicyPaths: undefined,
+ tools: undefined,
+ telemetry: undefined,
+ };
+ }
+ const safeMcpServers = settings.mcpServers;
+
+ const policySettings: PolicySettings = {
+ mcpServers: safeMcpServers,
+ tools: {
+ core: settings.tools?.core,
+ exclude: settings.tools?.exclude,
+ allowed: settings.tools?.allowed,
+ },
+ policyPaths: settings.policyPaths,
+ adminPolicyPaths: settings.adminPolicyPaths,
+ };
+
+ const policyEngineConfig = await createPolicyEngineConfig(
+ policySettings,
+ approvalMode,
+ undefined,
+ true,
+ );
+
+ const configParams: ConfigParameters = {
+ sessionId: taskId,
+ clientName: 'a2a-server',
+ model: PREVIEW_GEMINI_MODEL,
+ embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
+ sandbox: undefined, // Sandbox might not be relevant for a server-side agent
+ targetDir: workspaceDir, // Or a specific directory the agent operates on
+ debugMode: getEnvLocal('DEBUG') === 'true' || false,
+ question: '', // Not used in server mode directly like CLI
+ env: envVars,
+
+ coreTools: settings.tools?.core || undefined,
+ excludeTools: settings.tools?.exclude || undefined,
+ allowedTools: settings.tools?.allowed || undefined,
+ showMemoryUsage: settings.showMemoryUsage || false,
+ approvalMode,
+ policyEngineConfig,
+ mcpServers: safeMcpServers,
+ cwd: workspaceDir,
+ telemetry: {
+ enabled: settings.telemetry?.enabled,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ target: settings.telemetry?.target as TelemetryTarget,
+ otlpEndpoint:
+ getEnvLocal('OTEL_EXPORTER_OTLP_ENDPOINT') ??
+ settings.telemetry?.otlpEndpoint,
+ logPrompts: settings.telemetry?.logPrompts,
+ },
+ // Git-aware file filtering settings
+ fileFiltering: {
+ respectGitIgnore: settings.fileFiltering?.respectGitIgnore,
+ respectGeminiIgnore: settings.fileFiltering?.respectGeminiIgnore,
+ enableRecursiveFileSearch:
+ settings.fileFiltering?.enableRecursiveFileSearch,
+ customIgnoreFilePaths: [
+ ...(settings.fileFiltering?.customIgnoreFilePaths || []),
+ ...(getEnvLocal('CUSTOM_IGNORE_FILE_PATHS')
+ ? getEnvLocal('CUSTOM_IGNORE_FILE_PATHS').split(path.delimiter)
+ : []),
+ ],
+ },
+ ideMode: false,
+ folderTrust,
+ trustedFolder: trusted,
+ extensionLoader,
+ checkpointing,
+ interactive: true,
+ enableInteractiveShell: !isHeadlessMode(),
+ ptyInfo: 'auto',
+ enableAgents: settings.experimental?.enableAgents ?? true,
+ };
+
+ // Set an initial config to use to get a code assist server.
+ // This is needed to fetch admin controls.
+ const initialConfig = new Config({
+ ...configParams,
+ });
+
+ const codeAssistServer = getCodeAssistServer(initialConfig);
+
+ const adminControlsEnabled =
+ initialConfig.getExperiments()?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]
+ ?.boolValue ?? false;
+
+ // Initialize final config parameters to the previous parameters.
+ // If no admin controls are needed, these will be used as-is for the final
+ // config.
+ const finalConfigParams = { ...configParams };
+ if (adminControlsEnabled) {
+ const adminSettings = await fetchAdminControlsOnce(
+ codeAssistServer,
+ adminControlsEnabled,
+ );
+
+ // Admin settings are able to be undefined if unset, but if any are present,
+ // we should initialize them all.
+ // If any are present, undefined settings should be treated as if they were
+ // set to false.
+ // If NONE are present, disregard admin settings entirely, and pass the
+ // final config as is.
+ if (Object.keys(adminSettings).length !== 0) {
+ finalConfigParams.disableYoloMode = !adminSettings.strictModeDisabled;
+ finalConfigParams.mcpEnabled = adminSettings.mcpSetting?.mcpEnabled;
+ finalConfigParams.extensionsEnabled =
+ adminSettings.cliFeatureSetting?.extensionsSetting?.extensionsEnabled;
+ }
+ }
+
+ const config = new Config(finalConfigParams);
+
+ // Needed to initialize ToolRegistry, and git checkpointing if enabled
+ await config.initialize();
+
+ await config.waitForMcpInit();
+ startupProfiler.flush(config);
+
+ await refreshAuthentication(config, 'Config', envVars);
+
+ return config;
+}
+
+export function setIsTrusted(
+ agentSettings: AgentSettings | undefined,
+ workspaceRoot?: string,
+): boolean {
+ if (agentSettings?.isTrusted !== undefined) {
+ return agentSettings.isTrusted;
+ }
+ const cliTrustEnv = getEnv('GEMINI_CLI_TRUST_WORKSPACE');
+ if (cliTrustEnv !== undefined) {
+ return cliTrustEnv === 'true';
+ }
+ if (workspaceRoot) {
+ const initialSettings = loadSettings(workspaceRoot, false);
+ const { isTrusted } = checkPathTrust({
+ path: workspaceRoot,
+ isFolderTrustEnabled: initialSettings.folderTrust ?? true,
+ isHeadless: isHeadlessMode(),
+ });
+ return isTrusted ?? false;
+ }
+ return false;
+}
+
+export async function setTargetDir(
+ agentSettings: AgentSettings | undefined,
+): Promise {
+ const originalCWD = process.cwd();
+ const targetDir =
+ getEnv('CODER_AGENT_WORKSPACE_PATH') ??
+ (agentSettings?.kind === CoderAgentEvent.StateAgentSettingsEvent
+ ? agentSettings.workspacePath
+ : undefined);
+
+ if (!targetDir) {
+ return originalCWD;
+ }
+
+ logger.info(
+ `[CoderAgentExecutor] Overriding workspace path to: ${targetDir}`,
+ );
+
+ try {
+ let resolvedPath: string;
+ try {
+ resolvedPath = resolveToRealPath(targetDir);
+ } catch (err: unknown) {
+ if (
+ err &&
+ typeof err === 'object' &&
+ 'code' in err &&
+ err.code === 'ENOENT'
+ ) {
+ const parentDir = path.dirname(path.resolve(targetDir));
+ resolvedPath = path.join(
+ resolveToRealPath(parentDir),
+ path.basename(targetDir),
+ );
+ } else {
+ throw err;
+ }
+ }
+
+ const isTestEnv =
+ process.env['VITEST'] === 'true' ||
+ process.env['NODE_ENV'] === 'test' ||
+ process.argv.some((arg) => arg.includes('vitest')) ||
+ resolvedPath.startsWith(resolveToRealPath(tmpdir()));
+
+ const allowedRoot = resolveToRealPath(
+ getEnv('CODER_AGENT_ALLOWED_ROOT') ||
+ (isTestEnv ? path.parse(resolvedPath).root : homedir()),
+ );
+ const relative = path.relative(allowedRoot, resolvedPath);
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
+ throw new Error(
+ `Workspace path ${resolvedPath} is outside the allowed root directory`,
+ );
+ }
+
+ let stats: fs.Stats;
+ try {
+ stats = await fs.promises.stat(resolvedPath);
+ } catch (err: unknown) {
+ if (
+ err &&
+ typeof err === 'object' &&
+ 'code' in err &&
+ err.code === 'ENOENT'
+ ) {
+ if (isTestEnv) {
+ await fs.promises.mkdir(resolvedPath, { recursive: true });
+ stats = await fs.promises.stat(resolvedPath);
+ } else {
+ throw new Error(`Workspace path ${resolvedPath} does not exist`);
+ }
+ } else {
+ throw err;
+ }
+ }
+
+ if (!stats.isDirectory()) {
+ throw new Error(`Workspace path ${resolvedPath} is not a directory`);
+ }
+
+ return resolvedPath;
+ } catch (e) {
+ logger.error(`[CoderAgentExecutor] Error resolving workspace path: ${e}`);
+ throw e;
+ }
+}
+
+export async function loadEnvironment(
+ isTrusted: boolean = false,
+ workspacePath: string = process.cwd(),
+): Promise> {
+ // For untrusted workspaces, we completely bypass workspace-level .env loading
+ // and only load environment variables from the user's trusted home directory.
+ let envFilePath: string | null = null;
+ if (isTrusted) {
+ envFilePath = await findEnvFile(workspacePath);
+ } else {
+ const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
+ try {
+ await fs.promises.access(homeGeminiEnvPath);
+ envFilePath = homeGeminiEnvPath;
+ } catch {
+ const homeEnvPath = path.join(homedir(), '.env');
+ try {
+ await fs.promises.access(homeEnvPath);
+ envFilePath = homeEnvPath;
+ } catch {
+ // Ignore
+ }
+ }
+ }
+ const envVars: Record = {};
+ if (envFilePath) {
+ try {
+ const content = await fs.promises.readFile(envFilePath, 'utf-8');
+ const parsed = dotenv.parse(content);
+ for (const key in parsed) {
+ if (
+ Object.prototype.hasOwnProperty.call(parsed, key) &&
+ key !== '__proto__' &&
+ key !== 'constructor' &&
+ key !== 'prototype'
+ ) {
+ envVars[key] = parsed[key];
+ }
+ }
+ } catch {
+ // Ignore errors
+ }
+ }
+ return envVars;
+}
+
+async function findEnvFile(startDir: string): Promise {
+ let currentDir = path.resolve(startDir);
+ while (true) {
+ // prefer gemini-specific .env under GEMINI_DIR
+ const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
+ try {
+ await fs.promises.access(geminiEnvPath);
+ return geminiEnvPath;
+ } catch {
+ // Ignore
+ }
+ const envPath = path.join(currentDir, '.env');
+ try {
+ await fs.promises.access(envPath);
+ return envPath;
+ } catch {
+ // Ignore
+ }
+ const parentDir = path.dirname(currentDir);
+ if (parentDir === currentDir || !parentDir) {
+ break;
+ }
+ currentDir = parentDir;
+ }
+ // check .env under home as fallback, again preferring gemini-specific .env
+ const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
+ try {
+ await fs.promises.access(homeGeminiEnvPath);
+ return homeGeminiEnvPath;
+ } catch {
+ // Ignore
+ }
+ const homeEnvPath = path.join(homedir(), '.env');
+ try {
+ await fs.promises.access(homeEnvPath);
+ return homeEnvPath;
+ } catch {
+ return null;
+ }
+}
+
+async function refreshAuthentication(
+ config: Config,
+ logPrefix: string,
+ envVars: Record,
+): Promise {
+ const getEnvLocal = (key: string) => envVars[key];
+
+ if (getEnvLocal('USE_CCPA')) {
+ logger.info(`[${logPrefix}] Using CCPA Auth:`);
+
+ logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`);
+ try {
+ await config.refreshAuth(AuthType.COMPUTE_ADC);
+ logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
+ } catch (adcError) {
+ const adcMessage =
+ adcError instanceof Error ? adcError.message : String(adcError);
+ logger.info(
+ `[${logPrefix}] COMPUTE_ADC failed or not available: ${adcMessage}`,
+ );
+
+ const useComputeAdc =
+ getEnvLocal('GEMINI_CLI_USE_COMPUTE_ADC') === 'true';
+ const isHeadless = isHeadlessMode();
+
+ if (isHeadless || useComputeAdc) {
+ const reason = isHeadless
+ ? 'headless mode'
+ : 'GEMINI_CLI_USE_COMPUTE_ADC=true';
+ throw new FatalAuthenticationError(
+ `COMPUTE_ADC failed: ${adcMessage}. (LOGIN_WITH_GOOGLE fallback skipped due to ${reason}. Run in an interactive terminal to use OAuth.)`,
+ );
+ }
+
+ logger.info(
+ `[${logPrefix}] COMPUTE_ADC failed, falling back to LOGIN_WITH_GOOGLE.`,
+ );
+ try {
+ await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
+ } catch (e) {
+ if (e instanceof FatalAuthenticationError) {
+ const originalMessage = e instanceof Error ? e.message : String(e);
+ throw new FatalAuthenticationError(
+ `${originalMessage}. The initial COMPUTE_ADC attempt also failed: ${adcMessage}`,
+ );
+ }
+ throw e;
+ }
+ }
+
+ logger.info(
+ `[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${getEnvLocal('GOOGLE_CLOUD_PROJECT')}`,
+ );
+ } else if (getEnvLocal('GEMINI_API_KEY')) {
+ logger.info(`[${logPrefix}] Using Gemini API Key`);
+ await config.refreshAuth(
+ AuthType.USE_GEMINI,
+ getEnvLocal('GEMINI_API_KEY'),
+ );
+ } else {
+ const errorMessage = `[${logPrefix}] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.`;
+ logger.error(errorMessage);
+ throw new Error(errorMessage);
+ }
+}
diff --git a/packages/a2a-server/src/config/extension.ts b/packages/a2a-server/src/config/extension.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e0a69965d65b703a37569f4bac7f63be4d3675bc
--- /dev/null
+++ b/packages/a2a-server/src/config/extension.ts
@@ -0,0 +1,157 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// Copied exactly from packages/cli/src/config/extension.ts, last PR #1026
+
+import {
+ GEMINI_DIR,
+ type MCPServerConfig,
+ type ExtensionInstallMetadata,
+ type GeminiCLIExtension,
+ homedir,
+} from '@google/gemini-cli-core';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { logger } from '../utils/logger.js';
+
+export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
+export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
+export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json';
+
+/**
+ * Extension definition as written to disk in gemini-extension.json files.
+ * This should *not* be referenced outside of the logic for reading files.
+ * If information is required for manipulating extensions (load, unload, update)
+ * outside of the loading process that data needs to be stored on the
+ * GeminiCLIExtension class defined in Core.
+ */
+interface ExtensionConfig {
+ name: string;
+ version: string;
+ mcpServers?: Record;
+ contextFileName?: string | string[];
+ excludeTools?: string[];
+}
+
+export function loadExtensions(
+ workspaceDir: string,
+ isTrusted: boolean = false,
+): GeminiCLIExtension[] {
+ const allExtensions = [
+ ...(isTrusted ? loadExtensionsFromDir(workspaceDir) : []),
+ ...loadExtensionsFromDir(homedir()),
+ ];
+
+ const uniqueExtensions: GeminiCLIExtension[] = [];
+ const seenNames = new Set();
+ for (const extension of allExtensions) {
+ if (!seenNames.has(extension.name)) {
+ logger.info(
+ `Loading extension: ${extension.name} (version: ${extension.version})`,
+ );
+ uniqueExtensions.push(extension);
+ seenNames.add(extension.name);
+ }
+ }
+
+ return uniqueExtensions;
+}
+
+function loadExtensionsFromDir(dir: string): GeminiCLIExtension[] {
+ const extensionsDir = path.join(dir, EXTENSIONS_DIRECTORY_NAME);
+ if (!fs.existsSync(extensionsDir)) {
+ return [];
+ }
+
+ const extensions: GeminiCLIExtension[] = [];
+ for (const subdir of fs.readdirSync(extensionsDir)) {
+ const extensionDir = path.join(extensionsDir, subdir);
+
+ const extension = loadExtension(extensionDir);
+ if (extension != null) {
+ extensions.push(extension);
+ }
+ }
+ return extensions;
+}
+
+function loadExtension(extensionDir: string): GeminiCLIExtension | null {
+ if (!fs.statSync(extensionDir).isDirectory()) {
+ logger.error(
+ `Warning: unexpected file ${extensionDir} in extensions directory.`,
+ );
+ return null;
+ }
+
+ const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
+ if (!fs.existsSync(configFilePath)) {
+ logger.error(
+ `Warning: extension directory ${extensionDir} does not contain a config file ${configFilePath}.`,
+ );
+ return null;
+ }
+
+ try {
+ const configContent = fs.readFileSync(configFilePath, 'utf-8');
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const config = JSON.parse(configContent) as ExtensionConfig;
+ if (!config.name || !config.version) {
+ logger.error(
+ `Invalid extension config in ${configFilePath}: missing name or version.`,
+ );
+ return null;
+ }
+
+ const installMetadata = loadInstallMetadata(extensionDir);
+
+ const contextFiles = getContextFileNames(config)
+ .map((contextFileName) => path.join(extensionDir, contextFileName))
+ .filter((contextFilePath) => fs.existsSync(contextFilePath));
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ return {
+ name: config.name,
+ version: config.version,
+ path: extensionDir,
+ contextFiles,
+ installMetadata,
+ mcpServers: config.mcpServers,
+ excludeTools: config.excludeTools,
+ isActive: true, // Barring any other signals extensions should be considered Active.
+ } as GeminiCLIExtension;
+ } catch (e) {
+ logger.error(
+ `Warning: error parsing extension config in ${configFilePath}: ${e}`,
+ );
+ return null;
+ }
+}
+
+function getContextFileNames(config: ExtensionConfig): string[] {
+ if (!config.contextFileName) {
+ return ['GEMINI.md'];
+ } else if (!Array.isArray(config.contextFileName)) {
+ return [config.contextFileName];
+ }
+ return config.contextFileName;
+}
+
+export function loadInstallMetadata(
+ extensionDir: string,
+): ExtensionInstallMetadata | undefined {
+ const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
+ try {
+ const configContent = fs.readFileSync(metadataFilePath, 'utf-8');
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
+ return metadata;
+ } catch (e) {
+ logger.warn(
+ `Failed to load or parse extension install metadata at ${metadataFilePath}: ${e}`,
+ );
+ return undefined;
+ }
+}
diff --git a/packages/a2a-server/src/config/rce_vulnerability.test.ts b/packages/a2a-server/src/config/rce_vulnerability.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5b98c6e98086ea423aaca9dfe9070347ca3989ec
--- /dev/null
+++ b/packages/a2a-server/src/config/rce_vulnerability.test.ts
@@ -0,0 +1,115 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+
+const mockHomeRef = vi.hoisted(() => ({ dir: '' }));
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const original =
+ await importOriginal();
+ return {
+ ...original,
+ homedir: () => mockHomeRef.dir || os.tmpdir(),
+ };
+});
+
+import { loadEnvironment } from './config.js';
+
+describe('Vulnerability Mitigation: b-519269096', () => {
+ let tempWorkspaceDir: string;
+
+ beforeEach(() => {
+ // Create a temporary home directory securely using mkdtempSync to ensure hermeticity
+ mockHomeRef.dir = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'gemini-mock-home-'),
+ );
+
+ // Create a temporary workspace directory representing an untrusted repo
+ tempWorkspaceDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'gemini-exploit-workspace-'),
+ );
+ const geminiDir = path.join(tempWorkspaceDir, '.gemini');
+ fs.mkdirSync(geminiDir, { recursive: true });
+
+ // Mock process.cwd to return the untrusted workspace
+ vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+ fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
+ fs.rmSync(mockHomeRef.dir, { recursive: true, force: true });
+ });
+
+ it('should ignore GEMINI_CLI_TRUST_WORKSPACE and GEMINI_YOLO_MODE in untrusted workspaces', async () => {
+ const geminiDir = path.join(tempWorkspaceDir, '.gemini');
+ fs.writeFileSync(
+ path.join(geminiDir, '.env'),
+ 'GEMINI_CLI_TRUST_WORKSPACE=true\nGEMINI_YOLO_MODE=true\n',
+ );
+
+ // Ensure initially not set
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', '');
+ vi.stubEnv('GEMINI_YOLO_MODE', '');
+
+ // Act: load environment with isTrusted = false
+ const envVars = await loadEnvironment(false);
+
+ // Assert: In a SECURE system, these variables should NOT be loaded
+ expect(envVars['GEMINI_CLI_TRUST_WORKSPACE']).toBeUndefined();
+ expect(envVars['GEMINI_YOLO_MODE']).toBeUndefined();
+ expect(process.env['GEMINI_CLI_TRUST_WORKSPACE']).toBeFalsy();
+ expect(process.env['GEMINI_YOLO_MODE']).toBeFalsy();
+ });
+
+ it('should not load any variables from untrusted workspaces', async () => {
+ fs.writeFileSync(
+ path.join(tempWorkspaceDir, '.env'),
+ 'GEMINI_API_KEY=safe-key-123;rm -rf /\nGOOGLE_CLOUD_PROJECT=my-project\n',
+ );
+
+ // Ensure initially not set
+ vi.stubEnv('GEMINI_API_KEY', '');
+ vi.stubEnv('GOOGLE_CLOUD_PROJECT', '');
+
+ // Act: load environment with isTrusted = false
+ const envVars = await loadEnvironment(false);
+
+ // Assert: No variables should be loaded from the untrusted workspace
+ expect(envVars['GEMINI_API_KEY']).toBeUndefined();
+ expect(envVars['GOOGLE_CLOUD_PROJECT']).toBeUndefined();
+ expect(process.env['GEMINI_API_KEY']).toBeFalsy();
+ expect(process.env['GOOGLE_CLOUD_PROJECT']).toBeFalsy();
+ });
+
+ it('should load all variables in trusted workspaces with isolation', async () => {
+ const geminiDir = path.join(tempWorkspaceDir, '.gemini');
+ fs.writeFileSync(
+ path.join(geminiDir, '.env'),
+ 'GEMINI_CLI_TRUST_WORKSPACE=true\nGEMINI_YOLO_MODE=true\n',
+ );
+
+ // Ensure initially not set
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', '');
+ vi.stubEnv('GEMINI_YOLO_MODE', '');
+
+ // Load environment variables
+ const envVars = await loadEnvironment(true);
+
+ // Assert: In a trusted workspace, variables should be loaded
+ expect(envVars['GEMINI_CLI_TRUST_WORKSPACE']).toBe('true');
+ expect(envVars['GEMINI_YOLO_MODE']).toBe('true');
+
+ // Assert: Global process.env should NOT be polluted
+ expect(process.env['GEMINI_CLI_TRUST_WORKSPACE']).toBeFalsy();
+ expect(process.env['GEMINI_YOLO_MODE']).toBeFalsy();
+ });
+});
diff --git a/packages/a2a-server/src/config/settings.test.ts b/packages/a2a-server/src/config/settings.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..25f058fd8dec37062a64558a033a229940f119d9
--- /dev/null
+++ b/packages/a2a-server/src/config/settings.test.ts
@@ -0,0 +1,235 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { loadSettings, USER_SETTINGS_PATH } from './settings.js';
+import { debugLogger, checkPathTrust } from '@google/gemini-cli-core';
+
+const mocks = vi.hoisted(() => {
+ const suffix = Math.random().toString(36).slice(2);
+ return {
+ suffix,
+ };
+});
+
+vi.mock('node:os', async (importOriginal) => {
+ const actual = await importOriginal();
+ const path = await import('node:path');
+ return {
+ ...actual,
+ homedir: () => path.join(actual.tmpdir(), `gemini-home-${mocks.suffix}`),
+ };
+});
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ const path = await import('node:path');
+ const os = await import('node:os');
+ return {
+ ...actual,
+ GEMINI_DIR: '.gemini',
+ debugLogger: {
+ error: vi.fn(),
+ },
+ getErrorMessage: (error: unknown) => String(error),
+ homedir: () => path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`),
+ checkPathTrust: vi.fn(() => ({ isTrusted: false })),
+ isHeadlessMode: vi.fn(() => true),
+ };
+});
+
+describe('loadSettings', () => {
+ const mockHomeDir = path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`);
+ const mockWorkspaceDir = path.join(
+ os.tmpdir(),
+ `gemini-workspace-${mocks.suffix}`,
+ );
+ const mockGeminiHomeDir = path.join(mockHomeDir, '.gemini');
+ const mockGeminiWorkspaceDir = path.join(mockWorkspaceDir, '.gemini');
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Create the directories using the real fs
+ if (!fs.existsSync(mockGeminiHomeDir)) {
+ fs.mkdirSync(mockGeminiHomeDir, { recursive: true });
+ }
+ if (!fs.existsSync(mockGeminiWorkspaceDir)) {
+ fs.mkdirSync(mockGeminiWorkspaceDir, { recursive: true });
+ }
+
+ // Clean up settings files before each test
+ if (fs.existsSync(USER_SETTINGS_PATH)) {
+ fs.rmSync(USER_SETTINGS_PATH);
+ }
+ const workspaceSettingsPath = path.join(
+ mockGeminiWorkspaceDir,
+ 'settings.json',
+ );
+ if (fs.existsSync(workspaceSettingsPath)) {
+ fs.rmSync(workspaceSettingsPath);
+ }
+ });
+
+ afterEach(() => {
+ try {
+ if (fs.existsSync(mockHomeDir)) {
+ fs.rmSync(mockHomeDir, { recursive: true, force: true });
+ }
+ if (fs.existsSync(mockWorkspaceDir)) {
+ fs.rmSync(mockWorkspaceDir, { recursive: true, force: true });
+ }
+ } catch (e) {
+ debugLogger.error('Failed to cleanup temp dirs', e);
+ }
+ vi.restoreAllMocks();
+ });
+
+ it('should load other top-level settings correctly', () => {
+ const settings = {
+ showMemoryUsage: true,
+ tools: {
+ core: ['tool1', 'tool2'],
+ },
+ mcpServers: {
+ server1: {
+ command: 'cmd',
+ args: ['arg'],
+ },
+ },
+ fileFiltering: {
+ respectGitIgnore: true,
+ },
+ };
+ fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings));
+
+ const result = loadSettings(mockWorkspaceDir);
+ expect(result.showMemoryUsage).toBe(true);
+ expect(result.tools?.core).toEqual(['tool1', 'tool2']);
+ expect(result.mcpServers).toHaveProperty('server1');
+ expect(result.fileFiltering?.respectGitIgnore).toBe(true);
+ });
+
+ it('should load experimental settings correctly', () => {
+ const settings = {
+ experimental: {
+ enableAgents: true,
+ },
+ };
+ fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings));
+
+ const result = loadSettings(mockWorkspaceDir);
+ expect(result.experimental?.enableAgents).toBe(true);
+ });
+
+ it('should overwrite top-level settings from workspace (shallow merge)', () => {
+ const userSettings = {
+ showMemoryUsage: false,
+ fileFiltering: {
+ respectGitIgnore: true,
+ enableRecursiveFileSearch: true,
+ },
+ };
+ fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
+
+ const workspaceSettings = {
+ showMemoryUsage: true,
+ fileFiltering: {
+ respectGitIgnore: false,
+ },
+ };
+ const workspaceSettingsPath = path.join(
+ mockGeminiWorkspaceDir,
+ 'settings.json',
+ );
+ fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings));
+
+ const result = loadSettings(mockWorkspaceDir, true);
+ // Primitive value overwritten
+ expect(result.showMemoryUsage).toBe(true);
+
+ // Object value completely replaced (shallow merge behavior)
+ expect(result.fileFiltering?.respectGitIgnore).toBe(false);
+ expect(result.fileFiltering?.enableRecursiveFileSearch).toBeUndefined();
+ });
+
+ describe('security', () => {
+ it('should NOT load workspace settings if workspace is NOT trusted', () => {
+ const userSettings = { showMemoryUsage: false };
+ fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
+
+ const workspaceSettings = { showMemoryUsage: true };
+ const workspaceSettingsPath = path.join(
+ mockGeminiWorkspaceDir,
+ 'settings.json',
+ );
+ fs.writeFileSync(
+ workspaceSettingsPath,
+ JSON.stringify(workspaceSettings),
+ );
+
+ // checkPathTrust is mocked to return isTrusted: false by default
+ const result = loadSettings(mockWorkspaceDir);
+ expect(result.showMemoryUsage).toBe(false);
+ });
+
+ it('should load workspace settings if workspace IS trusted', () => {
+ vi.mocked(checkPathTrust).mockReturnValueOnce({
+ isTrusted: true,
+ source: 'file',
+ });
+ const userSettings = { showMemoryUsage: false };
+ fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
+
+ const workspaceSettings = { showMemoryUsage: true };
+ const workspaceSettingsPath = path.join(
+ mockGeminiWorkspaceDir,
+ 'settings.json',
+ );
+ fs.writeFileSync(
+ workspaceSettingsPath,
+ JSON.stringify(workspaceSettings),
+ );
+
+ const result = loadSettings(mockWorkspaceDir);
+ expect(result.showMemoryUsage).toBe(true);
+ });
+
+ it('should NOT allow workspace settings to override adminPolicyPaths or policyPaths even if trusted', () => {
+ vi.mocked(checkPathTrust).mockReturnValueOnce({
+ isTrusted: true,
+ source: 'file',
+ });
+ const userSettings = {
+ adminPolicyPaths: ['/trusted/admin'],
+ policyPaths: ['/trusted/user'],
+ };
+ fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
+
+ const workspaceSettings = {
+ adminPolicyPaths: ['./malicious/admin'],
+ policyPaths: ['./malicious/user'],
+ showMemoryUsage: true,
+ };
+ const workspaceSettingsPath = path.join(
+ mockGeminiWorkspaceDir,
+ 'settings.json',
+ );
+ fs.writeFileSync(
+ workspaceSettingsPath,
+ JSON.stringify(workspaceSettings),
+ );
+
+ const result = loadSettings(mockWorkspaceDir);
+ expect(result.showMemoryUsage).toBe(true);
+ expect(result.adminPolicyPaths).toEqual(['/trusted/admin']);
+ expect(result.policyPaths).toEqual(['/trusted/user']);
+ });
+ });
+});
diff --git a/packages/a2a-server/src/config/settings.ts b/packages/a2a-server/src/config/settings.ts
new file mode 100644
index 0000000000000000000000000000000000000000..754f2b4b942bb09531df1b37092bda4ce891a039
--- /dev/null
+++ b/packages/a2a-server/src/config/settings.ts
@@ -0,0 +1,204 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+
+import {
+ type MCPServerConfig,
+ debugLogger,
+ GEMINI_DIR,
+ getErrorMessage,
+ type TelemetrySettings,
+ homedir,
+ checkPathTrust,
+ isHeadlessMode,
+} from '@google/gemini-cli-core';
+import stripJsonComments from 'strip-json-comments';
+
+export const USER_SETTINGS_DIR = path.join(homedir(), GEMINI_DIR);
+export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json');
+
+// TODO: Ensure full compatibility with V2 nested settings structure (settings.schema.json).
+// This involves updating the interface and implementing migration logic to support legacy V1 (flat) settings,
+// similar to how packages/cli/src/config/settings.ts handles it.
+export interface Settings {
+ mcpServers?: Record;
+ tools?: {
+ allowed?: string[];
+ exclude?: string[];
+ core?: string[];
+ };
+ telemetry?: TelemetrySettings;
+ showMemoryUsage?: boolean;
+ checkpointing?: CheckpointingSettings;
+ folderTrust?: boolean;
+ general?: {
+ previewFeatures?: boolean;
+ };
+
+ // Git-aware file filtering settings
+ fileFiltering?: {
+ respectGitIgnore?: boolean;
+ respectGeminiIgnore?: boolean;
+ enableRecursiveFileSearch?: boolean;
+ customIgnoreFilePaths?: string[];
+ };
+ experimental?: {
+ enableAgents?: boolean;
+ };
+ policyPaths?: string[];
+ adminPolicyPaths?: string[];
+}
+
+export interface SettingsError {
+ message: string;
+ path: string;
+}
+
+export interface CheckpointingSettings {
+ enabled?: boolean;
+}
+
+/**
+ * Loads settings from user and workspace directories.
+ * Project settings override user settings if the workspace is trusted.
+ *
+ * How is it different to gemini-cli/cli: Returns already merged settings rather
+ * than `LoadedSettings` (unnecessary since we are not modifying users
+ * settings.json).
+ */
+export function loadSettings(
+ workspaceDir: string,
+ isTrustedOverride?: boolean,
+): Settings {
+ let userSettings: Settings = {};
+ let workspaceSettings: Settings = {};
+ const settingsErrors: SettingsError[] = [];
+
+ // Load user settings
+ try {
+ if (fs.existsSync(USER_SETTINGS_PATH)) {
+ const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const parsedUserSettings = JSON.parse(
+ stripJsonComments(userContent),
+ ) as Settings;
+ userSettings = resolveEnvVarsInObject(parsedUserSettings);
+ }
+ } catch (error: unknown) {
+ settingsErrors.push({
+ message: getErrorMessage(error),
+ path: USER_SETTINGS_PATH,
+ });
+ }
+
+ let isTrusted = isTrustedOverride;
+ if (isTrusted === undefined) {
+ const { isTrusted: trustResult } = checkPathTrust({
+ path: workspaceDir,
+ isFolderTrustEnabled: userSettings.folderTrust ?? true,
+ isHeadless: isHeadlessMode(),
+ });
+ isTrusted = trustResult ?? false;
+ }
+
+ const workspaceSettingsPath = path.join(
+ workspaceDir,
+ GEMINI_DIR,
+ 'settings.json',
+ );
+
+ // Load workspace settings only if trusted
+ if (isTrusted) {
+ try {
+ if (fs.existsSync(workspaceSettingsPath)) {
+ const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const parsedWorkspaceSettings = JSON.parse(
+ stripJsonComments(projectContent),
+ ) as Settings;
+ workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
+ }
+ } catch (error: unknown) {
+ settingsErrors.push({
+ message: getErrorMessage(error),
+ path: workspaceSettingsPath,
+ });
+ }
+ }
+
+ if (settingsErrors.length > 0) {
+ debugLogger.error('Errors loading settings:');
+ for (const error of settingsErrors) {
+ debugLogger.error(` Path: ${error.path}`);
+ debugLogger.error(` Message: ${error.message}`);
+ }
+ }
+
+ // If there are overlapping keys, the values of workspaceSettings will
+ // override values from userSettings
+ const mergedSettings = {
+ ...userSettings,
+ ...workspaceSettings,
+ };
+
+ // Security: ensure policyPaths and adminPolicyPaths are only loaded from trusted, user-level
+ // configuration and cannot be overridden by workspace-level settings, even if the
+ // workspace is trusted.
+ mergedSettings.policyPaths = userSettings.policyPaths;
+ mergedSettings.adminPolicyPaths = userSettings.adminPolicyPaths;
+
+ return mergedSettings;
+}
+
+function resolveEnvVarsInString(value: string): string {
+ const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME}
+ return value.replace(
+ envVarRegex,
+ (match: string, varName1: string, varName2: string) => {
+ const varName = varName1 || varName2;
+ const envValue = process?.env?.[varName];
+ if (typeof envValue === 'string') {
+ return envValue;
+ }
+ return match;
+ },
+ );
+}
+
+function resolveEnvVarsInObject(obj: T): T {
+ if (
+ obj === null ||
+ obj === undefined ||
+ typeof obj === 'boolean' ||
+ typeof obj === 'number'
+ ) {
+ return obj;
+ }
+
+ if (typeof obj === 'string') {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ return resolveEnvVarsInString(obj) as unknown as T;
+ }
+
+ if (Array.isArray(obj)) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
+ return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T;
+ }
+
+ if (typeof obj === 'object') {
+ const newObj = { ...obj } as T;
+ for (const key in newObj) {
+ if (Object.prototype.hasOwnProperty.call(newObj, key)) {
+ newObj[key] = resolveEnvVarsInObject(newObj[key]);
+ }
+ }
+ return newObj;
+ }
+
+ return obj;
+}
diff --git a/packages/a2a-server/src/config/workspace_trust.test.ts b/packages/a2a-server/src/config/workspace_trust.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d632908f86838e1fa27ac6da31063da141fb50c4
--- /dev/null
+++ b/packages/a2a-server/src/config/workspace_trust.test.ts
@@ -0,0 +1,179 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+vi.unmock('@google/gemini-cli-core');
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import {
+ Config,
+ checkPathTrust,
+ SimpleExtensionLoader,
+} from '@google/gemini-cli-core';
+import * as core from '@google/gemini-cli-core';
+import { loadConfig } from './config.js';
+import type { Settings } from './settings.js';
+
+describe('Workspace Trust Evaluation', () => {
+ let tempWorkspaceDir: string;
+
+ beforeEach(() => {
+ tempWorkspaceDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'gemini-trust-eval-'),
+ );
+ const geminiDir = path.join(tempWorkspaceDir, '.gemini');
+ fs.mkdirSync(geminiDir, { recursive: true });
+
+ vi.stubEnv('GEMINI_API_KEY', 'dummy-key-for-eval');
+ vi.spyOn(Config.prototype, 'initialize').mockResolvedValue(undefined);
+ vi.spyOn(Config.prototype, 'waitForMcpInit').mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+ fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
+ });
+
+ it('resolves untrusted environment signals in checkPathTrust', () => {
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'false');
+
+ const result = checkPathTrust({
+ path: tempWorkspaceDir,
+ isFolderTrustEnabled: true,
+ });
+
+ expect(result).toEqual({ isTrusted: false, source: 'env' });
+
+ vi.stubEnv('GEMINI_RESTRICTED_MODE', 'true');
+
+ const restrictedResult = checkPathTrust({
+ path: tempWorkspaceDir,
+ isFolderTrustEnabled: true,
+ });
+
+ expect(restrictedResult).toEqual({ isTrusted: false, source: 'env' });
+ });
+
+ it('evaluates to false in Config.isTrustedFolder when untrusted signals are present or undefined', () => {
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'false');
+
+ const untrustedConfig = new Config({
+ sessionId: 'test-session',
+ clientName: 'test-client',
+ model: 'gemini-pro',
+ targetDir: tempWorkspaceDir,
+ cwd: tempWorkspaceDir,
+ debugMode: false,
+ question: '',
+ folderTrust: false,
+ trustedFolder: undefined,
+ });
+
+ expect(untrustedConfig.isTrustedFolder()).toBe(false);
+
+ vi.unstubAllEnvs();
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', '');
+
+ const fallbackConfig = new Config({
+ sessionId: 'test-session',
+ clientName: 'test-client',
+ model: 'gemini-pro',
+ targetDir: tempWorkspaceDir,
+ cwd: tempWorkspaceDir,
+ debugMode: false,
+ question: '',
+ folderTrust: true,
+ trustedFolder: undefined,
+ });
+
+ expect(fallbackConfig.isTrustedFolder()).toBe(false);
+ });
+
+ it('assigns undefined to mcpServers, policyPaths, adminPolicyPaths, tools, and telemetry in configParams and policySettings when trusted is false', async () => {
+ const policySpy = vi.spyOn(core, 'createPolicyEngineConfig');
+ const settings: Settings = {
+ mcpServers: {
+ 'test-tool': {
+ command: 'node',
+ args: ['-e', 'process.exit(0)'],
+ },
+ },
+ policyPaths: ['/test/policy/path'],
+ adminPolicyPaths: ['/test/admin/policy/path'],
+ tools: {
+ allowed: ['shell'],
+ },
+ telemetry: {
+ otlpEndpoint: 'http://untrusted-endpoint:4317',
+ },
+ };
+
+ const config = await loadConfig(
+ settings,
+ new SimpleExtensionLoader([]),
+ 'test-untrusted-task',
+ false,
+ tempWorkspaceDir,
+ );
+
+ expect(config.getMcpServers()).toBeUndefined();
+ expect(policySpy.mock.calls[0][0].mcpServers).toBeUndefined();
+ expect(policySpy.mock.calls[0][0].policyPaths).toBeUndefined();
+ expect(policySpy.mock.calls[0][0].adminPolicyPaths).toBeUndefined();
+ expect(policySpy.mock.calls[0][0].tools?.allowed).toBeUndefined();
+ expect(config.getTelemetryOtlpEndpoint()).not.toBe(
+ 'http://untrusted-endpoint:4317',
+ );
+ expect(settings.mcpServers).toBeDefined();
+ expect(settings.policyPaths).toBeDefined();
+ expect(settings.adminPolicyPaths).toBeDefined();
+ expect(settings.tools).toBeDefined();
+ expect(settings.telemetry).toBeDefined();
+ });
+
+ it('retains mcpServers, policyPaths, adminPolicyPaths, tools, and telemetry in configParams and policySettings when trusted is true', async () => {
+ const policySpy = vi.spyOn(core, 'createPolicyEngineConfig');
+ const settings: Settings = {
+ mcpServers: {
+ 'test-tool': {
+ command: 'node',
+ args: ['-e', 'process.exit(0)'],
+ },
+ },
+ policyPaths: ['/test/policy/path'],
+ adminPolicyPaths: ['/test/admin/policy/path'],
+ tools: {
+ allowed: ['shell'],
+ },
+ telemetry: {
+ otlpEndpoint: 'http://trusted-endpoint:4317',
+ },
+ };
+
+ const config = await loadConfig(
+ settings,
+ new SimpleExtensionLoader([]),
+ 'test-trusted-task',
+ true,
+ tempWorkspaceDir,
+ );
+
+ expect(config.getMcpServers()).toEqual(settings.mcpServers);
+ expect(policySpy.mock.calls[0][0].mcpServers).toEqual(settings.mcpServers);
+ expect(policySpy.mock.calls[0][0].policyPaths).toEqual(
+ settings.policyPaths,
+ );
+ expect(policySpy.mock.calls[0][0].adminPolicyPaths).toEqual(
+ settings.adminPolicyPaths,
+ );
+ expect(policySpy.mock.calls[0][0].tools?.allowed).toEqual(['shell']);
+ expect(config.getTelemetryOtlpEndpoint()).toBe(
+ 'http://trusted-endpoint:4317',
+ );
+ });
+});
diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7a716dd617469be343ce81b68cb50794049413da
--- /dev/null
+++ b/packages/a2a-server/src/http/app.test.ts
@@ -0,0 +1,1266 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ GeminiEventType,
+ ApprovalMode,
+ type Config,
+ type ToolCallConfirmationDetails,
+} from '@google/gemini-cli-core';
+import type {
+ TaskStatusUpdateEvent,
+ SendStreamingMessageSuccessResponse,
+} from '@a2a-js/sdk';
+import express from 'express';
+import type { Server } from 'node:http';
+import request from 'supertest';
+import {
+ afterAll,
+ afterEach,
+ beforeEach,
+ beforeAll,
+ describe,
+ expect,
+ it,
+ vi,
+} from 'vitest';
+import { createApp, main } from './app.js';
+import { commandRegistry } from '../commands/command-registry.js';
+import {
+ assertUniqueFinalEventIsLast,
+ assertTaskCreationAndWorkingStatus,
+ createStreamMessageRequest,
+ createMockConfig,
+} from '../utils/testing_utils.js';
+// Import MockTool from specific path to avoid vitest dependency in main core bundle
+import { MockTool } from '@google/gemini-cli-core/src/test-utils/mock-tool.js';
+import type { Command, CommandContext } from '../commands/types.js';
+
+const mockToolConfirmationFn = async () =>
+ ({}) as unknown as ToolCallConfirmationDetails;
+
+const streamToSSEEvents = (
+ stream: string,
+): SendStreamingMessageSuccessResponse[] =>
+ stream
+ .split('\n\n')
+ .filter(Boolean) // Remove empty strings from trailing newlines
+ .map((chunk) => {
+ const dataLine = chunk
+ .split('\n')
+ .find((line) => line.startsWith('data: '));
+ if (!dataLine) {
+ throw new Error(`Invalid SSE chunk found: "${chunk}"`);
+ }
+ return JSON.parse(dataLine.substring(6));
+ });
+
+// Mock the logger to avoid polluting test output
+// Comment out to debug tests
+vi.mock('../utils/logger.js', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+
+let config: Config;
+const getToolRegistrySpy = vi.fn().mockReturnValue({
+ getTool: vi.fn(),
+ getAllToolNames: vi.fn().mockReturnValue([]),
+ getAllTools: vi.fn().mockReturnValue([]),
+ getToolsByServer: vi.fn().mockReturnValue([]),
+});
+const getApprovalModeSpy = vi.fn();
+const getShellExecutionConfigSpy = vi.fn();
+const getExtensionsSpy = vi.fn();
+
+vi.mock('../config/config.js', async () => {
+ const actual = await vi.importActual('../config/config.js');
+ return {
+ ...actual,
+ loadConfig: vi.fn().mockImplementation(async () => {
+ const mockConfig = createMockConfig({
+ getToolRegistry: getToolRegistrySpy,
+ getApprovalMode: getApprovalModeSpy,
+ getShellExecutionConfig: getShellExecutionConfigSpy,
+ getExtensions: getExtensionsSpy,
+ });
+ config = mockConfig as Config;
+ return config;
+ }),
+ };
+});
+
+// Mock the GeminiClient to avoid actual API calls
+const sendMessageStreamSpy = vi.fn();
+vi.mock('@google/gemini-cli-core', async () => {
+ const actual = await vi.importActual('@google/gemini-cli-core');
+ return {
+ ...actual,
+ GeminiClient: vi.fn().mockImplementation(() => ({
+ sendMessageStream: sendMessageStreamSpy,
+ getUserTier: vi.fn().mockReturnValue('free'),
+ initialize: vi.fn(),
+ })),
+ performRestore: vi.fn(),
+ };
+});
+
+describe('E2E Tests', () => {
+ let app: express.Express;
+ let server: Server;
+
+ beforeAll(async () => {
+ app = await createApp();
+ server = app.listen(0); // Listen on a random available port
+ });
+
+ beforeEach(() => {
+ getApprovalModeSpy.mockReturnValue(ApprovalMode.DEFAULT);
+ });
+
+ afterAll(
+ () =>
+ new Promise((resolve) => {
+ server.close(() => {
+ resolve();
+ });
+ }),
+ );
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.unstubAllEnvs();
+ });
+
+ it('should create a new task and stream status updates (text-content) via POST /', async () => {
+ sendMessageStreamSpy.mockImplementation(async function* () {
+ yield* [{ type: 'content', value: 'Hello how are you?' }];
+ });
+
+ const agent = request.agent(app);
+ const res = await agent
+ .post('/')
+ .send(createStreamMessageRequest('hello', 'a2a-test-message'))
+ .set('Content-Type', 'application/json')
+ .expect(200);
+
+ const events = streamToSSEEvents(res.text);
+
+ assertTaskCreationAndWorkingStatus(events);
+
+ // Status update: text-content
+ const textContentEvent = events[2].result as TaskStatusUpdateEvent;
+ expect(textContentEvent.kind).toBe('status-update');
+ expect(textContentEvent.status.state).toBe('working');
+ expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({
+ kind: 'text-content',
+ });
+ expect(textContentEvent.status.message?.parts).toMatchObject([
+ { kind: 'text', text: 'Hello how are you?' },
+ ]);
+
+ // Status update: input-required (final)
+ const finalEvent = events[3].result as TaskStatusUpdateEvent;
+ expect(finalEvent.kind).toBe('status-update');
+ expect(finalEvent.status?.state).toBe('input-required');
+ expect(finalEvent.final).toBe(true);
+
+ assertUniqueFinalEventIsLast(events);
+ expect(events.length).toBe(4);
+ });
+
+ it('should create a new task, schedule a tool call, and wait for approval', async () => {
+ // First call yields the tool request
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
+ yield* [
+ {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'test-call-id',
+ name: 'test-tool',
+ args: {},
+ },
+ },
+ ];
+ });
+ // Subsequent calls yield nothing
+ sendMessageStreamSpy.mockImplementation(async function* () {
+ yield* [];
+ });
+
+ const mockTool = new MockTool({
+ name: 'test-tool',
+ shouldConfirmExecute: vi.fn(mockToolConfirmationFn),
+ });
+
+ getToolRegistrySpy.mockReturnValue({
+ getAllTools: vi.fn().mockReturnValue([mockTool]),
+ getToolsByServer: vi.fn().mockReturnValue([]),
+ getTool: vi.fn().mockReturnValue(mockTool),
+ });
+
+ const agent = request.agent(app);
+ const res = await agent
+ .post('/')
+ .send(createStreamMessageRequest('run a tool', 'a2a-tool-test-message'))
+ .set('Content-Type', 'application/json')
+ .expect(200);
+
+ const events = streamToSSEEvents(res.text);
+ assertTaskCreationAndWorkingStatus(events);
+
+ // Status update: working
+ const workingEvent2 = events[2].result as TaskStatusUpdateEvent;
+ expect(workingEvent2.kind).toBe('status-update');
+ expect(workingEvent2.status.state).toBe('working');
+ expect(workingEvent2.metadata?.['coderAgent']).toMatchObject({
+ kind: 'state-change',
+ });
+
+ // Status update: tool-call-update
+ const toolCallUpdateEvent = events[3].result as TaskStatusUpdateEvent;
+ expect(toolCallUpdateEvent.kind).toBe('status-update');
+ expect(toolCallUpdateEvent.status.state).toBe('working');
+ expect(toolCallUpdateEvent.metadata?.['coderAgent']).toMatchObject({
+ kind: 'tool-call-update',
+ });
+ expect(toolCallUpdateEvent.status.message?.parts).toMatchObject([
+ {
+ data: {
+ status: 'scheduled',
+ request: { callId: 'test-call-id' },
+ },
+ },
+ ]);
+
+ // State update: awaiting_approval update
+ const toolCallConfirmationEvent = events[4].result as TaskStatusUpdateEvent;
+ expect(toolCallConfirmationEvent.kind).toBe('status-update');
+ expect(toolCallConfirmationEvent.metadata?.['coderAgent']).toMatchObject({
+ kind: 'tool-call-confirmation',
+ });
+ expect(toolCallConfirmationEvent.status.message?.parts).toMatchObject([
+ {
+ data: {
+ status: 'awaiting_approval',
+ request: { callId: 'test-call-id' },
+ },
+ },
+ ]);
+ expect(toolCallConfirmationEvent.status?.state).toBe('working');
+
+ assertUniqueFinalEventIsLast(events);
+ expect(events.length).toBe(6);
+ });
+
+ it('should handle multiple tool calls in a single turn', async () => {
+ // First call yields the tool request
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
+ yield* [
+ {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'test-call-id-1',
+ name: 'test-tool-1',
+ args: {},
+ },
+ },
+ {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'test-call-id-2',
+ name: 'test-tool-2',
+ args: {},
+ },
+ },
+ ];
+ });
+ // Subsequent calls yield nothing
+ sendMessageStreamSpy.mockImplementation(async function* () {
+ yield* [];
+ });
+
+ const mockTool1 = new MockTool({
+ name: 'test-tool-1',
+ displayName: 'Test Tool 1',
+ shouldConfirmExecute: vi.fn(mockToolConfirmationFn),
+ });
+ const mockTool2 = new MockTool({
+ name: 'test-tool-2',
+ displayName: 'Test Tool 2',
+ shouldConfirmExecute: vi.fn(mockToolConfirmationFn),
+ });
+
+ getToolRegistrySpy.mockReturnValue({
+ getAllTools: vi.fn().mockReturnValue([mockTool1, mockTool2]),
+ getToolsByServer: vi.fn().mockReturnValue([]),
+ getTool: vi.fn().mockImplementation((name: string) => {
+ if (name === 'test-tool-1') return mockTool1;
+ if (name === 'test-tool-2') return mockTool2;
+ return undefined;
+ }),
+ });
+
+ const agent = request.agent(app);
+ const res = await agent
+ .post('/')
+ .send(
+ createStreamMessageRequest(
+ 'run two tools',
+ 'a2a-multi-tool-test-message',
+ ),
+ )
+ .set('Content-Type', 'application/json')
+ .expect(200);
+
+ const events = streamToSSEEvents(res.text);
+ assertTaskCreationAndWorkingStatus(events);
+
+ // Second working update
+ const workingEvent = events[2].result as TaskStatusUpdateEvent;
+ expect(workingEvent.kind).toBe('status-update');
+ expect(workingEvent.status.state).toBe('working');
+
+ // State Update: Validate the first tool call
+ const toolCallValidateEvent1 = events[3].result as TaskStatusUpdateEvent;
+ expect(toolCallValidateEvent1.metadata?.['coderAgent']).toMatchObject({
+ kind: 'tool-call-update',
+ });
+ expect(toolCallValidateEvent1.status.message?.parts).toMatchObject([
+ {
+ data: {
+ status: 'scheduled',
+ request: { callId: 'test-call-id-1' },
+ },
+ },
+ ]);
+
+ // --- Assert the event stream ---
+ // 1. Initial "submitted" status.
+ expect((events[0].result as TaskStatusUpdateEvent).status.state).toBe(
+ 'submitted',
+ );
+
+ // 2. "working" status after receiving the user prompt.
+ expect((events[1].result as TaskStatusUpdateEvent).status.state).toBe(
+ 'working',
+ );
+
+ // 3. A "state-change" event from the agent.
+ expect(events[2].result.metadata?.['coderAgent']).toMatchObject({
+ kind: 'state-change',
+ });
+
+ // 4. Tool 1 is scheduled.
+ const toolCallUpdate1 = events[3].result as TaskStatusUpdateEvent;
+ expect(toolCallUpdate1.metadata?.['coderAgent']).toMatchObject({
+ kind: 'tool-call-update',
+ });
+ expect(toolCallUpdate1.status.message?.parts).toMatchObject([
+ {
+ data: {
+ request: { callId: 'test-call-id-1' },
+ status: 'scheduled',
+ },
+ },
+ ]);
+
+ // 5. Tool 2 is scheduled.
+ const toolCallUpdate2 = events[4].result as TaskStatusUpdateEvent;
+ expect(toolCallUpdate2.metadata?.['coderAgent']).toMatchObject({
+ kind: 'tool-call-update',
+ });
+ expect(toolCallUpdate2.status.message?.parts).toMatchObject([
+ {
+ data: {
+ request: { callId: 'test-call-id-2' },
+ status: 'scheduled',
+ },
+ },
+ ]);
+
+ // 6. Tool 1 is awaiting approval.
+ const toolCallAwaitEvent1 = events[5].result as TaskStatusUpdateEvent;
+ expect(toolCallAwaitEvent1.metadata?.['coderAgent']).toMatchObject({
+ kind: 'tool-call-confirmation',
+ });
+ expect(toolCallAwaitEvent1.status.message?.parts).toMatchObject([
+ {
+ data: {
+ request: { callId: 'test-call-id-1' },
+ status: 'awaiting_approval',
+ },
+ },
+ ]);
+
+ // 7. Tool 2 is awaiting approval.
+ const toolCallAwaitEvent2 = events[6].result as TaskStatusUpdateEvent;
+ expect(toolCallAwaitEvent2.metadata?.['coderAgent']).toMatchObject({
+ kind: 'tool-call-confirmation',
+ });
+ expect(toolCallAwaitEvent2.status.message?.parts).toMatchObject([
+ {
+ data: {
+ request: { callId: 'test-call-id-2' },
+ status: 'awaiting_approval',
+ },
+ },
+ ]);
+
+ // 8. The final event is "input-required".
+ const finalEvent = events[7].result as TaskStatusUpdateEvent;
+ expect(finalEvent.final).toBe(true);
+ expect(finalEvent.status.state).toBe('input-required');
+
+ // The scheduler now waits for approval, so no more events are sent.
+ assertUniqueFinalEventIsLast(events);
+ expect(events.length).toBe(8);
+ });
+
+ it('should handle multiple tool calls sequentially in YOLO mode', async () => {
+ // Set YOLO mode to auto-approve tools and test sequential execution.
+ getApprovalModeSpy.mockReturnValue(ApprovalMode.YOLO);
+
+ // First call yields the tool request
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
+ yield* [
+ {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'test-call-id-1',
+ name: 'test-tool-1',
+ args: {},
+ },
+ },
+ {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'test-call-id-2',
+ name: 'test-tool-2',
+ args: {},
+ },
+ },
+ ];
+ });
+ // Subsequent calls yield nothing, as the tools will "succeed".
+ sendMessageStreamSpy.mockImplementation(async function* () {
+ yield* [{ type: 'content', value: 'All tools executed.' }];
+ });
+
+ const mockTool1 = new MockTool({
+ name: 'test-tool-1',
+ displayName: 'Test Tool 1',
+ shouldConfirmExecute: vi.fn(mockToolConfirmationFn),
+ execute: vi
+ .fn()
+ .mockResolvedValue({ llmContent: 'tool 1 done', returnDisplay: '' }),
+ });
+ const mockTool2 = new MockTool({
+ name: 'test-tool-2',
+ displayName: 'Test Tool 2',
+ shouldConfirmExecute: vi.fn(mockToolConfirmationFn),
+ execute: vi
+ .fn()
+ .mockResolvedValue({ llmContent: 'tool 2 done', returnDisplay: '' }),
+ });
+
+ getToolRegistrySpy.mockReturnValue({
+ getAllTools: vi.fn().mockReturnValue([mockTool1, mockTool2]),
+ getToolsByServer: vi.fn().mockReturnValue([]),
+ getTool: vi.fn().mockImplementation((name: string) => {
+ if (name === 'test-tool-1') return mockTool1;
+ if (name === 'test-tool-2') return mockTool2;
+ return undefined;
+ }),
+ });
+
+ const agent = request.agent(app);
+ const res = await agent
+ .post('/')
+ .send(
+ createStreamMessageRequest(
+ 'run two tools',
+ 'a2a-multi-tool-test-message',
+ ),
+ )
+ .set('Content-Type', 'application/json')
+ .expect(200);
+
+ const events = streamToSSEEvents(res.text);
+ assertTaskCreationAndWorkingStatus(events);
+
+ // --- Assert the sequential execution flow ---
+ const eventStream = events.slice(2).map((e) => {
+ const update = e.result as TaskStatusUpdateEvent;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const agentData = update.metadata?.['coderAgent'] as any;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const toolData = update.status.message?.parts[0] as any;
+ if (!toolData) {
+ return { kind: agentData.kind };
+ }
+ return {
+ kind: agentData.kind,
+ status: toolData.data?.status,
+ callId: toolData.data?.request.callId,
+ };
+ });
+
+ const expectedFlow = [
+ // Initial state change
+ { kind: 'state-change', status: undefined, callId: undefined },
+ // Tool 1 Lifecycle
+ {
+ kind: 'tool-call-update',
+ status: 'scheduled',
+ callId: 'test-call-id-1',
+ },
+ {
+ kind: 'tool-call-update',
+ status: 'scheduled',
+ callId: 'test-call-id-1',
+ },
+ {
+ kind: 'tool-call-update',
+ status: 'executing',
+ callId: 'test-call-id-1',
+ },
+ {
+ kind: 'tool-call-update',
+ status: 'success',
+ callId: 'test-call-id-1',
+ },
+ // Tool 2 Lifecycle
+ {
+ kind: 'tool-call-update',
+ status: 'scheduled',
+ callId: 'test-call-id-2',
+ },
+ {
+ kind: 'tool-call-update',
+ status: 'scheduled',
+ callId: 'test-call-id-2',
+ },
+ {
+ kind: 'tool-call-update',
+ status: 'executing',
+ callId: 'test-call-id-2',
+ },
+ {
+ kind: 'tool-call-update',
+ status: 'success',
+ callId: 'test-call-id-2',
+ },
+ // Final updates
+ { kind: 'state-change', status: undefined, callId: undefined },
+ { kind: 'text-content', status: undefined, callId: undefined },
+ ];
+
+ // Use `toContainEqual` for flexibility if other events are interspersed.
+ expect(eventStream).toEqual(expect.arrayContaining(expectedFlow));
+
+ assertUniqueFinalEventIsLast(events);
+ });
+
+ it('should handle tool calls that do not require approval', async () => {
+ // First call yields the tool request
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
+ yield* [
+ {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'test-call-id-no-approval',
+ name: 'test-tool-no-approval',
+ args: {},
+ },
+ },
+ ];
+ });
+ // Second call, after the tool runs, yields the final text
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
+ yield* [{ type: 'content', value: 'Tool executed successfully.' }];
+ });
+
+ const mockTool = new MockTool({
+ name: 'test-tool-no-approval',
+ displayName: 'Test Tool No Approval',
+ execute: vi.fn().mockResolvedValue({
+ llmContent: 'Tool executed successfully.',
+ returnDisplay: 'Tool executed successfully.',
+ }),
+ });
+
+ getToolRegistrySpy.mockReturnValue({
+ getAllTools: vi.fn().mockReturnValue([mockTool]),
+ getToolsByServer: vi.fn().mockReturnValue([]),
+ getTool: vi.fn().mockReturnValue(mockTool),
+ });
+
+ const agent = request.agent(app);
+ const res = await agent
+ .post('/')
+ .send(
+ createStreamMessageRequest(
+ 'run a tool without approval',
+ 'a2a-no-approval-test-message',
+ ),
+ )
+ .set('Content-Type', 'application/json')
+ .expect(200);
+
+ const events = streamToSSEEvents(res.text);
+ assertTaskCreationAndWorkingStatus(events);
+
+ // Status update: working
+ const workingEvent2 = events[2].result as TaskStatusUpdateEvent;
+ expect(workingEvent2.kind).toBe('status-update');
+ expect(workingEvent2.status.state).toBe('working');
+
+ // Status update: tool-call-update (scheduled)
+ const scheduledEvent1 = events[3].result as TaskStatusUpdateEvent;
+ expect(scheduledEvent1.metadata?.['coderAgent']).toMatchObject({
+ kind: 'tool-call-update',
+ });
+ expect(scheduledEvent1.status.message?.parts).toMatchObject([
+ {
+ data: {
+ status: 'scheduled',
+ request: { callId: 'test-call-id-no-approval' },
+ },
+ },
+ ]);
+
+ // Status update: tool-call-update (scheduled)
+ const scheduledEvent2 = events[4].result as TaskStatusUpdateEvent;
+ expect(scheduledEvent2.metadata?.['coderAgent']).toMatchObject({
+ kind: 'tool-call-update',
+ });
+ expect(scheduledEvent2.status.message?.parts).toMatchObject([
+ {
+ data: {
+ status: 'scheduled',
+ request: { callId: 'test-call-id-no-approval' },
+ },
+ },
+ ]);
+
+ // Status update: tool-call-update (scheduled)
+ const scheduledEvent3 = events[5].result as TaskStatusUpdateEvent;
+ expect(scheduledEvent3.metadata?.['coderAgent']).toMatchObject({
+ kind: 'tool-call-update',
+ });
+ expect(scheduledEvent3.status.message?.parts).toMatchObject([
+ {
+ data: {
+ status: 'scheduled',
+ request: { callId: 'test-call-id-no-approval' },
+ },
+ },
+ ]);
+
+ // Status update: tool-call-update (executing)
+ const executingEvent = events[6].result as TaskStatusUpdateEvent;
+ expect(executingEvent.metadata?.['coderAgent']).toMatchObject({
+ kind: 'tool-call-update',
+ });
+ expect(executingEvent.status.message?.parts).toMatchObject([
+ {
+ data: {
+ status: 'executing',
+ request: { callId: 'test-call-id-no-approval' },
+ },
+ },
+ ]);
+
+ // Status update: tool-call-update (success)
+ const successEvent = events[7].result as TaskStatusUpdateEvent;
+ expect(successEvent.metadata?.['coderAgent']).toMatchObject({
+ kind: 'tool-call-update',
+ });
+ expect(successEvent.status.message?.parts).toMatchObject([
+ {
+ data: {
+ status: 'success',
+ request: { callId: 'test-call-id-no-approval' },
+ },
+ },
+ ]);
+
+ // Status update: working (before sending tool result to LLM)
+ const workingEvent3 = events[8].result as TaskStatusUpdateEvent;
+ expect(workingEvent3.kind).toBe('status-update');
+ expect(workingEvent3.status.state).toBe('working');
+
+ // Status update: text-content (final LLM response)
+ const textContentEvent = events[9].result as TaskStatusUpdateEvent;
+ expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({
+ kind: 'text-content',
+ });
+ expect(textContentEvent.status.message?.parts).toMatchObject([
+ { text: 'Tool executed successfully.' },
+ ]);
+
+ assertUniqueFinalEventIsLast(events);
+ expect(events.length).toBe(11);
+ });
+
+ it('should bypass tool approval in YOLO mode', async () => {
+ // First call yields the tool request
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
+ yield* [
+ {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'test-call-id-yolo',
+ name: 'test-tool-yolo',
+ args: {},
+ },
+ },
+ ];
+ });
+ // Second call, after the tool runs, yields the final text
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
+ yield* [{ type: 'content', value: 'Tool executed successfully.' }];
+ });
+
+ // Set approval mode to yolo
+ getApprovalModeSpy.mockReturnValue(ApprovalMode.YOLO);
+
+ const mockTool = new MockTool({
+ name: 'test-tool-yolo',
+ displayName: 'Test Tool YOLO',
+ execute: vi.fn().mockResolvedValue({
+ llmContent: 'Tool executed successfully.',
+ returnDisplay: 'Tool executed successfully.',
+ }),
+ });
+
+ getToolRegistrySpy.mockReturnValue({
+ getAllTools: vi.fn().mockReturnValue([mockTool]),
+ getToolsByServer: vi.fn().mockReturnValue([]),
+ getTool: vi.fn().mockReturnValue(mockTool),
+ });
+
+ const agent = request.agent(app);
+ const res = await agent
+ .post('/')
+ .send(
+ createStreamMessageRequest(
+ 'run a tool in yolo mode',
+ 'a2a-yolo-mode-test-message',
+ ),
+ )
+ .set('Content-Type', 'application/json')
+ .expect(200);
+
+ const events = streamToSSEEvents(res.text);
+ assertTaskCreationAndWorkingStatus(events);
+
+ // Status update: working
+ const workingEvent2 = events[2].result as TaskStatusUpdateEvent;
+ expect(workingEvent2.kind).toBe('status-update');
+ expect(workingEvent2.status.state).toBe('working');
+
+ // Status update: tool-call-update (scheduled)
+ const scheduledEvent = events[3].result as TaskStatusUpdateEvent;
+ expect(scheduledEvent.metadata?.['coderAgent']).toMatchObject({
+ kind: 'tool-call-update',
+ });
+ expect(scheduledEvent.status.message?.parts).toMatchObject([
+ {
+ data: {
+ status: 'scheduled',
+ request: { callId: 'test-call-id-yolo' },
+ },
+ },
+ ]);
+
+ // Status update: tool-call-update (scheduled)
+ const awaitingEvent = events[4].result as TaskStatusUpdateEvent;
+ expect(awaitingEvent.metadata?.['coderAgent']).toMatchObject({
+ kind: 'tool-call-update',
+ });
+ expect(awaitingEvent.status.message?.parts).toMatchObject([
+ {
+ data: {
+ status: 'scheduled',
+ request: { callId: 'test-call-id-yolo' },
+ },
+ },
+ ]);
+
+ // Status update: tool-call-update (scheduled)
+ const scheduledEvent3 = events[5].result as TaskStatusUpdateEvent;
+ expect(scheduledEvent3.metadata?.['coderAgent']).toMatchObject({
+ kind: 'tool-call-update',
+ });
+ expect(scheduledEvent3.status.message?.parts).toMatchObject([
+ {
+ data: {
+ status: 'scheduled',
+ request: { callId: 'test-call-id-yolo' },
+ },
+ },
+ ]);
+
+ // Status update: tool-call-update (executing)
+ const executingEvent = events[6].result as TaskStatusUpdateEvent;
+ expect(executingEvent.metadata?.['coderAgent']).toMatchObject({
+ kind: 'tool-call-update',
+ });
+ expect(executingEvent.status.message?.parts).toMatchObject([
+ {
+ data: {
+ status: 'executing',
+ request: { callId: 'test-call-id-yolo' },
+ },
+ },
+ ]);
+
+ // Status update: tool-call-update (success)
+ const successEvent = events[7].result as TaskStatusUpdateEvent;
+ expect(successEvent.metadata?.['coderAgent']).toMatchObject({
+ kind: 'tool-call-update',
+ });
+ expect(successEvent.status.message?.parts).toMatchObject([
+ {
+ data: {
+ status: 'success',
+ request: { callId: 'test-call-id-yolo' },
+ },
+ },
+ ]);
+
+ // Status update: working (before sending tool result to LLM)
+ const workingEvent3 = events[8].result as TaskStatusUpdateEvent;
+ expect(workingEvent3.kind).toBe('status-update');
+ expect(workingEvent3.status.state).toBe('working');
+
+ // Status update: text-content (final LLM response)
+ const textContentEvent = events[9].result as TaskStatusUpdateEvent;
+ expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({
+ kind: 'text-content',
+ });
+ expect(textContentEvent.status.message?.parts).toMatchObject([
+ { text: 'Tool executed successfully.' },
+ ]);
+
+ assertUniqueFinalEventIsLast(events);
+ expect(events.length).toBe(11);
+ });
+
+ it('should include traceId in status updates when available', async () => {
+ const traceId = 'test-trace-id';
+ sendMessageStreamSpy.mockImplementation(async function* () {
+ yield* [
+ { type: 'content', value: 'Hello', traceId },
+ { type: 'thought', value: { subject: 'Thinking...' }, traceId },
+ ];
+ });
+
+ const agent = request.agent(app);
+ const res = await agent
+ .post('/')
+ .send(createStreamMessageRequest('hello', 'a2a-trace-id-test'))
+ .set('Content-Type', 'application/json')
+ .expect(200);
+
+ const events = streamToSSEEvents(res.text);
+
+ // The first two events are task-creation and working status
+ const textContentEvent = events[2].result as TaskStatusUpdateEvent;
+ expect(textContentEvent.kind).toBe('status-update');
+ expect(textContentEvent.metadata?.['traceId']).toBe(traceId);
+
+ const thoughtEvent = events[3].result as TaskStatusUpdateEvent;
+ expect(thoughtEvent.kind).toBe('status-update');
+ expect(thoughtEvent.metadata?.['traceId']).toBe(traceId);
+ });
+
+ describe('/listCommands', () => {
+ it('should return a list of top-level commands', async () => {
+ const mockCommands = [
+ {
+ name: 'test-command',
+ description: 'A test command',
+ topLevel: true,
+ arguments: [{ name: 'arg1', description: 'Argument 1' }],
+ subCommands: [
+ {
+ name: 'sub-command',
+ description: 'A sub command',
+ topLevel: false,
+ execute: vi.fn(),
+ },
+ ],
+ execute: vi.fn(),
+ },
+ {
+ name: 'another-command',
+ description: 'Another test command',
+ topLevel: true,
+ execute: vi.fn(),
+ },
+ {
+ name: 'not-top-level',
+ description: 'Not a top level command',
+ topLevel: false,
+ execute: vi.fn(),
+ },
+ ];
+
+ const getAllCommandsSpy = vi
+ .spyOn(commandRegistry, 'getAllCommands')
+ .mockReturnValue(mockCommands);
+
+ const agent = request.agent(app);
+ const res = await agent.get('/listCommands').expect(200);
+
+ expect(res.body).toEqual({
+ commands: [
+ {
+ name: 'test-command',
+ description: 'A test command',
+ arguments: [{ name: 'arg1', description: 'Argument 1' }],
+ subCommands: [
+ {
+ name: 'sub-command',
+ description: 'A sub command',
+ arguments: [],
+ subCommands: [],
+ },
+ ],
+ },
+ {
+ name: 'another-command',
+ description: 'Another test command',
+ arguments: [],
+ subCommands: [],
+ },
+ ],
+ });
+
+ expect(getAllCommandsSpy).toHaveBeenCalledOnce();
+ getAllCommandsSpy.mockRestore();
+ });
+
+ it('should handle cyclic commands gracefully', async () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ const cyclicCommand: Command = {
+ name: 'cyclic-command',
+ description: 'A cyclic command',
+ topLevel: true,
+ execute: vi.fn(),
+ subCommands: [],
+ };
+ cyclicCommand.subCommands?.push(cyclicCommand); // Create cycle
+
+ const getAllCommandsSpy = vi
+ .spyOn(commandRegistry, 'getAllCommands')
+ .mockReturnValue([cyclicCommand]);
+
+ const agent = request.agent(app);
+ const res = await agent.get('/listCommands').expect(200);
+
+ expect(res.body.commands[0].name).toBe('cyclic-command');
+ expect(res.body.commands[0].subCommands).toEqual([]);
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ 'Command cyclic-command already inserted in the response, skipping',
+ );
+
+ getAllCommandsSpy.mockRestore();
+ warnSpy.mockRestore();
+ });
+ });
+
+ describe('/executeCommand', () => {
+ const mockExtensions = [{ name: 'test-extension', version: '0.0.1' }];
+
+ beforeEach(() => {
+ getExtensionsSpy.mockReturnValue(mockExtensions);
+ });
+
+ afterEach(() => {
+ getExtensionsSpy.mockClear();
+ });
+
+ it('should return extensions for valid command', async () => {
+ const mockExtensionsCommand = {
+ name: 'extensions list',
+ description: 'a mock command',
+ execute: vi.fn(async (context: CommandContext) => {
+ // Simulate the actual command's behavior
+ const extensions = context.config.getExtensions();
+ return { name: 'extensions list', data: extensions };
+ }),
+ };
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockExtensionsCommand);
+
+ const agent = request.agent(app);
+ const res = await agent
+ .post('/executeCommand')
+ .send({ command: 'extensions list', args: [] })
+ .set('Content-Type', 'application/json')
+ .expect(200);
+
+ expect(res.body).toEqual({
+ name: 'extensions list',
+ data: mockExtensions,
+ });
+ expect(getExtensionsSpy).toHaveBeenCalled();
+ });
+
+ it('should return 404 for invalid command', async () => {
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(undefined);
+
+ const agent = request.agent(app);
+ const res = await agent
+ .post('/executeCommand')
+ .send({ command: 'invalid command' })
+ .set('Content-Type', 'application/json')
+ .expect(404);
+
+ expect(res.body.error).toBe('Command not found: invalid command');
+ expect(getExtensionsSpy).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 for missing command', async () => {
+ const agent = request.agent(app);
+ await agent
+ .post('/executeCommand')
+ .send({ args: [] })
+ .set('Content-Type', 'application/json')
+ .expect(400);
+ expect(getExtensionsSpy).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 if args is not an array', async () => {
+ const agent = request.agent(app);
+ const res = await agent
+ .post('/executeCommand')
+ .send({ command: 'extensions.list', args: 'not-an-array' })
+ .set('Content-Type', 'application/json')
+ .expect(400);
+
+ expect(res.body.error).toBe('"args" field must be an array.');
+ expect(getExtensionsSpy).not.toHaveBeenCalled();
+ });
+
+ it('should execute a command that does not require a workspace when CODER_AGENT_WORKSPACE_PATH is not set', async () => {
+ const mockCommand = {
+ name: 'test-command',
+ description: 'a mock command',
+ execute: vi
+ .fn()
+ .mockResolvedValue({ name: 'test-command', data: 'success' }),
+ };
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockCommand);
+
+ vi.stubEnv('CODER_AGENT_WORKSPACE_PATH', '');
+ const response = await request(app)
+ .post('/executeCommand')
+ .send({ command: 'test-command', args: [] });
+
+ expect(response.status).toBe(200);
+ expect(response.body.data).toBe('success');
+ });
+
+ it('should return 400 for a command that requires a workspace when CODER_AGENT_WORKSPACE_PATH is not set', async () => {
+ const mockWorkspaceCommand = {
+ name: 'workspace-command',
+ description: 'A command that requires a workspace',
+ requiresWorkspace: true,
+ execute: vi
+ .fn()
+ .mockResolvedValue({ name: 'workspace-command', data: 'success' }),
+ };
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockWorkspaceCommand);
+
+ vi.stubEnv('CODER_AGENT_WORKSPACE_PATH', '');
+ const response = await request(app)
+ .post('/executeCommand')
+ .send({ command: 'workspace-command', args: [] });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe(
+ 'Command "workspace-command" requires a workspace, but CODER_AGENT_WORKSPACE_PATH is not set.',
+ );
+ });
+
+ it('should execute a command that requires a workspace when CODER_AGENT_WORKSPACE_PATH is set', async () => {
+ const mockWorkspaceCommand = {
+ name: 'workspace-command',
+ description: 'A command that requires a workspace',
+ requiresWorkspace: true,
+ execute: vi
+ .fn()
+ .mockResolvedValue({ name: 'workspace-command', data: 'success' }),
+ };
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockWorkspaceCommand);
+
+ vi.stubEnv('CODER_AGENT_WORKSPACE_PATH', '/tmp/test-workspace');
+ const response = await request(app)
+ .post('/executeCommand')
+ .send({ command: 'workspace-command', args: [] });
+
+ expect(response.status).toBe(200);
+ expect(response.body.data).toBe('success');
+ });
+
+ it('should include agentExecutor in context', async () => {
+ const mockCommand = {
+ name: 'context-check-command',
+ description: 'checks context',
+ execute: vi.fn(async (context: CommandContext) => {
+ if (!context.agentExecutor) {
+ throw new Error('agentExecutor missing');
+ }
+ return { name: 'context-check-command', data: 'success' };
+ }),
+ };
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockCommand);
+
+ const agent = request.agent(app);
+ const res = await agent
+ .post('/executeCommand')
+ .send({ command: 'context-check-command', args: [] })
+ .set('Content-Type', 'application/json')
+ .expect(200);
+
+ expect(res.body.data).toBe('success');
+ });
+
+ describe('/executeCommand streaming', () => {
+ it('should execute a streaming command and stream back events', (done: (
+ err?: unknown,
+ ) => void) => {
+ const executeSpy = vi.fn(async (context: CommandContext) => {
+ context.eventBus?.publish({
+ kind: 'status-update',
+ status: { state: 'working' },
+ taskId: 'test-task',
+ contextId: 'test-context',
+ final: false,
+ });
+ context.eventBus?.publish({
+ kind: 'status-update',
+ status: { state: 'completed' },
+ taskId: 'test-task',
+ contextId: 'test-context',
+ final: true,
+ });
+ return { name: 'stream-test', data: 'done' };
+ });
+
+ const mockStreamCommand = {
+ name: 'stream-test',
+ description: 'A test streaming command',
+ streaming: true,
+ execute: executeSpy,
+ };
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockStreamCommand);
+
+ const agent = request.agent(app);
+ agent
+ .post('/executeCommand')
+ .send({ command: 'stream-test', args: [] })
+ .set('Content-Type', 'application/json')
+ .set('Accept', 'text/event-stream')
+ .on('response', (res) => {
+ let data = '';
+ res.on('data', (chunk: Buffer) => {
+ data += chunk.toString();
+ });
+ res.on('end', () => {
+ try {
+ const events = streamToSSEEvents(data);
+ expect(events.length).toBe(2);
+ expect(events[0].result).toEqual({
+ kind: 'status-update',
+ status: { state: 'working' },
+ taskId: 'test-task',
+ contextId: 'test-context',
+ final: false,
+ });
+ expect(events[1].result).toEqual({
+ kind: 'status-update',
+ status: { state: 'completed' },
+ taskId: 'test-task',
+ contextId: 'test-context',
+ final: true,
+ });
+ expect(executeSpy).toHaveBeenCalled();
+ done();
+ } catch (e) {
+ done(e);
+ }
+ });
+ })
+ .end();
+ });
+
+ it('should handle non-streaming commands gracefully', async () => {
+ const mockNonStreamCommand = {
+ name: 'non-stream-test',
+ description: 'A test non-streaming command',
+ execute: vi
+ .fn()
+ .mockResolvedValue({ name: 'non-stream-test', data: 'done' }),
+ };
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockNonStreamCommand);
+
+ const agent = request.agent(app);
+ const res = await agent
+ .post('/executeCommand')
+ .send({ command: 'non-stream-test', args: [] })
+ .set('Content-Type', 'application/json')
+ .expect(200);
+
+ expect(res.body).toEqual({ name: 'non-stream-test', data: 'done' });
+ });
+ });
+ });
+
+ describe('main', () => {
+ it('should listen on localhost only', async () => {
+ const listenSpy = vi
+ .spyOn(express.application, 'listen')
+ .mockImplementation((...args: unknown[]) => {
+ // Trigger the callback passed to listen
+ const callback = args.find(
+ (arg): arg is () => void => typeof arg === 'function',
+ );
+ if (callback) {
+ callback();
+ }
+
+ return {
+ address: () => ({ port: 1234 }),
+ on: vi.fn(),
+ once: vi.fn(),
+ emit: vi.fn(),
+ } as unknown as Server;
+ });
+
+ // Avoid process.exit if possible, or mock it if main might fail
+ const exitSpy = vi
+ .spyOn(process, 'exit')
+ .mockImplementation(() => undefined as never);
+
+ await main();
+
+ expect(listenSpy).toHaveBeenCalledWith(
+ expect.any(Number),
+ 'localhost',
+ expect.any(Function),
+ );
+
+ listenSpy.mockRestore();
+ exitSpy.mockRestore();
+ });
+ });
+});
diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts
new file mode 100644
index 0000000000000000000000000000000000000000..762c809a07a2f1a9821cf8dbf1a80d03b0ccadd3
--- /dev/null
+++ b/packages/a2a-server/src/http/app.ts
@@ -0,0 +1,435 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import express, { type Request } from 'express';
+
+import type { AgentCard, Message } from '@a2a-js/sdk';
+import {
+ type TaskStore,
+ DefaultRequestHandler,
+ InMemoryTaskStore,
+ DefaultExecutionEventBus,
+ type AgentExecutionEvent,
+ UnauthenticatedUser,
+} from '@a2a-js/sdk/server';
+import { A2AExpressApp, type UserBuilder } from '@a2a-js/sdk/server/express'; // Import server components
+import { v4 as uuidv4 } from 'uuid';
+import { logger } from '../utils/logger.js';
+import type { AgentSettings } from '../types.js';
+import { GCSTaskStore, NoOpTaskStore } from '../persistence/gcs.js';
+import { CoderAgentExecutor } from '../agent/executor.js';
+import { requestStorage } from './requestStorage.js';
+import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js';
+import { loadSettings } from '../config/settings.js';
+import { loadExtensions } from '../config/extension.js';
+import { commandRegistry } from '../commands/command-registry.js';
+import {
+ debugLogger,
+ SimpleExtensionLoader,
+ GitService,
+ checkPathTrust,
+ isHeadlessMode,
+} from '@google/gemini-cli-core';
+import type { Command, CommandArgument } from '../commands/types.js';
+
+type CommandResponse = {
+ name: string;
+ description: string;
+ arguments: CommandArgument[];
+ subCommands: CommandResponse[];
+};
+
+const coderAgentCard: AgentCard = {
+ name: 'Gemini SDLC Agent',
+ description:
+ 'An agent that generates code based on natural language instructions and streams file outputs.',
+ url: 'http://localhost:41242/',
+ provider: {
+ organization: 'Google',
+ url: 'https://google.com',
+ },
+ protocolVersion: '0.3.0',
+ version: '0.0.2', // Incremented version
+ capabilities: {
+ streaming: true,
+ pushNotifications: false,
+ stateTransitionHistory: true,
+ },
+ securitySchemes: {
+ bearerAuth: {
+ type: 'http',
+ scheme: 'bearer',
+ },
+ basicAuth: {
+ type: 'http',
+ scheme: 'basic',
+ },
+ },
+ security: [{ bearerAuth: [] }, { basicAuth: [] }],
+ defaultInputModes: ['text'],
+ defaultOutputModes: ['text'],
+ skills: [
+ {
+ id: 'code_generation',
+ name: 'Code Generation',
+ description:
+ 'Generates code snippets or complete files based on user requests, streaming the results.',
+ tags: ['code', 'development', 'programming'],
+ examples: [
+ 'Write a python function to calculate fibonacci numbers.',
+ 'Create an HTML file with a basic button that alerts "Hello!" when clicked.',
+ ],
+ inputModes: ['text'],
+ outputModes: ['text'],
+ },
+ ],
+ supportsAuthenticatedExtendedCard: false,
+};
+
+export function updateCoderAgentCardUrl(port: number) {
+ coderAgentCard.url = `http://localhost:${port}/`;
+}
+
+const customUserBuilder: UserBuilder = async (req: Request) => {
+ const auth = req.headers['authorization'];
+ if (auth) {
+ const scheme = auth.split(' ')[0];
+ logger.info(
+ `[customUserBuilder] Received Authorization header with scheme: ${scheme}`,
+ );
+ }
+ if (!auth) return new UnauthenticatedUser();
+
+ // 1. Bearer Auth
+ if (auth.startsWith('Bearer ')) {
+ const token = auth.substring(7);
+ if (token === 'valid-token') {
+ return { userName: 'bearer-user', isAuthenticated: true };
+ }
+ }
+
+ // 2. Basic Auth
+ if (auth.startsWith('Basic ')) {
+ const credentials = Buffer.from(auth.substring(6), 'base64').toString();
+ if (credentials === 'admin:password') {
+ return { userName: 'basic-user', isAuthenticated: true };
+ }
+ }
+
+ return new UnauthenticatedUser();
+};
+
+async function handleExecuteCommand(
+ req: express.Request,
+ res: express.Response,
+ context: {
+ config: Awaited>;
+ git: GitService | undefined;
+ agentExecutor: CoderAgentExecutor;
+ },
+) {
+ logger.info('[CoreAgent] Received /executeCommand request: ', req.body);
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const { command, args } = req.body;
+ try {
+ if (typeof command !== 'string') {
+ return res.status(400).json({ error: 'Invalid "command" field.' });
+ }
+
+ if (args && !Array.isArray(args)) {
+ return res.status(400).json({ error: '"args" field must be an array.' });
+ }
+
+ const commandToExecute = commandRegistry.get(command);
+
+ if (commandToExecute?.requiresWorkspace) {
+ if (!process.env['CODER_AGENT_WORKSPACE_PATH']) {
+ return res.status(400).json({
+ error: `Command "${command}" requires a workspace, but CODER_AGENT_WORKSPACE_PATH is not set.`,
+ });
+ }
+ }
+
+ if (!commandToExecute) {
+ return res.status(404).json({ error: `Command not found: ${command}` });
+ }
+
+ if (commandToExecute.streaming) {
+ const eventBus = new DefaultExecutionEventBus();
+ res.setHeader('Content-Type', 'text/event-stream');
+ const eventHandler = (event: AgentExecutionEvent) => {
+ const jsonRpcResponse = {
+ jsonrpc: '2.0',
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ id: 'taskId' in event ? event.taskId : (event as Message).messageId,
+ result: event,
+ };
+ res.write(`data: ${JSON.stringify(jsonRpcResponse)}\n`);
+ };
+ eventBus.on('event', eventHandler);
+
+ await commandToExecute.execute({ ...context, eventBus }, args ?? []);
+
+ eventBus.off('event', eventHandler);
+ eventBus.finished();
+ return res.end(); // Explicit return for streaming path
+ } else {
+ const result = await commandToExecute.execute(context, args ?? []);
+ logger.info('[CoreAgent] Sending /executeCommand response: ', result);
+ return res.status(200).json(result);
+ }
+ } catch (e) {
+ logger.error(
+ `Error executing /executeCommand: ${command} with args: ${JSON.stringify(
+ args,
+ )}`,
+ e,
+ );
+ const errorMessage =
+ e instanceof Error ? e.message : 'Unknown error executing command';
+ return res.status(500).json({ error: errorMessage });
+ }
+}
+
+export async function createApp() {
+ try {
+ // Load the server configuration once on startup.
+ const workspaceRoot = await setTargetDir(undefined);
+
+ // Use a temporary settings load to check if folder trust is enabled.
+ // This is similar to how the CLI handles the initial trust check.
+ const initialSettings = loadSettings(workspaceRoot, false);
+ const { isTrusted } = checkPathTrust({
+ path: workspaceRoot,
+ isFolderTrustEnabled: initialSettings.folderTrust ?? true,
+ isHeadless: isHeadlessMode(),
+ });
+
+ // Change the global working directory to the workspace root during startup
+ process.chdir(workspaceRoot);
+
+ // Load environment globally for the server startup
+ const globalEnv = await loadEnvironment(isTrusted ?? false, workspaceRoot);
+ // Only assign safe server-config variables to process.env to prevent credential leakage
+ const allowedServerKeys = [
+ 'CODER_AGENT_PORT',
+ 'CODER_AGENT_WORKSPACE_PATH',
+ 'GCS_BUCKET_NAME',
+ 'LOG_LEVEL',
+ 'GOOGLE_APPLICATION_CREDENTIALS',
+ 'GOOGLE_CLOUD_PROJECT',
+ 'GEMINI_CLI_USE_COMPUTE_ADC',
+ ];
+ for (const key of allowedServerKeys) {
+ if (globalEnv[key] !== undefined) {
+ process.env[key] = globalEnv[key];
+ }
+ }
+
+ const settings = loadSettings(workspaceRoot, isTrusted ?? false);
+ const extensions = loadExtensions(workspaceRoot, isTrusted ?? false);
+ const config = await loadConfig(
+ settings,
+ new SimpleExtensionLoader(extensions),
+ 'a2a-server',
+ isTrusted ?? false,
+ workspaceRoot,
+ );
+
+ let git: GitService | undefined;
+ if (config.getCheckpointingEnabled()) {
+ git = new GitService(config.getTargetDir(), config.storage);
+ await git.initialize();
+ }
+
+ // loadEnvironment() is called within getConfig now
+ const bucketName = process.env['GCS_BUCKET_NAME'];
+ let taskStoreForExecutor: TaskStore;
+ let taskStoreForHandler: TaskStore;
+
+ if (bucketName) {
+ logger.info(`Using GCSTaskStore with bucket: ${bucketName}`);
+ const gcsTaskStore = new GCSTaskStore(bucketName);
+ taskStoreForExecutor = gcsTaskStore;
+ taskStoreForHandler = new NoOpTaskStore(gcsTaskStore);
+ } else {
+ logger.info('Using InMemoryTaskStore');
+ const inMemoryTaskStore = new InMemoryTaskStore();
+ taskStoreForExecutor = inMemoryTaskStore;
+ taskStoreForHandler = inMemoryTaskStore;
+ }
+
+ const agentExecutor = new CoderAgentExecutor(taskStoreForExecutor);
+
+ const context = { config, git, agentExecutor };
+
+ const requestHandler = new DefaultRequestHandler(
+ coderAgentCard,
+ taskStoreForHandler,
+ agentExecutor,
+ );
+
+ let expressApp = express();
+ expressApp.use((req, res, next) => {
+ requestStorage.run({ req }, next);
+ });
+
+ const appBuilder = new A2AExpressApp(requestHandler, customUserBuilder);
+ expressApp = appBuilder.setupRoutes(expressApp, '');
+ expressApp.use(express.json());
+
+ expressApp.post('/tasks', async (req, res) => {
+ try {
+ const taskId = uuidv4();
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const agentSettings = req.body.agentSettings as
+ | AgentSettings
+ | undefined;
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const contextId = req.body.contextId || uuidv4();
+ const wrapper = await agentExecutor.createTask(
+ taskId,
+ contextId,
+ agentSettings,
+ );
+ await taskStoreForExecutor.save(wrapper.toSDKTask());
+ res.status(201).json(wrapper.id);
+ } catch (error) {
+ logger.error('[CoreAgent] Error creating task:', error);
+ const errorMessage =
+ error instanceof Error
+ ? error.message
+ : 'Unknown error creating task';
+ res.status(500).send({ error: errorMessage });
+ }
+ });
+
+ expressApp.post('/executeCommand', (req, res) => {
+ void handleExecuteCommand(req, res, context);
+ });
+
+ expressApp.get('/listCommands', (req, res) => {
+ try {
+ const transformCommand = (
+ command: Command,
+ visited: string[],
+ ): CommandResponse | undefined => {
+ const commandName = command.name;
+ if (visited.includes(commandName)) {
+ debugLogger.warn(
+ `Command ${commandName} already inserted in the response, skipping`,
+ );
+ return undefined;
+ }
+
+ return {
+ name: command.name,
+ description: command.description,
+ arguments: command.arguments ?? [],
+ subCommands: (command.subCommands ?? [])
+ .map((subCommand) =>
+ transformCommand(subCommand, visited.concat(commandName)),
+ )
+ .filter(
+ (subCommand): subCommand is CommandResponse => !!subCommand,
+ ),
+ };
+ };
+
+ const commands = commandRegistry
+ .getAllCommands()
+ .filter((command) => command.topLevel)
+ .map((command) => transformCommand(command, []));
+
+ return res.status(200).json({ commands });
+ } catch (e) {
+ logger.error('Error executing /listCommands:', e);
+ const errorMessage =
+ e instanceof Error ? e.message : 'Unknown error listing commands';
+ return res.status(500).json({ error: errorMessage });
+ }
+ });
+
+ expressApp.get('/tasks/metadata', async (req, res) => {
+ // This endpoint is only meaningful if the task store is in-memory.
+ if (!(taskStoreForExecutor instanceof InMemoryTaskStore)) {
+ res.status(501).send({
+ error:
+ 'Listing all task metadata is only supported when using InMemoryTaskStore.',
+ });
+ return;
+ }
+ try {
+ const wrappers = agentExecutor.getAllTasks();
+ if (wrappers && wrappers.length > 0) {
+ const tasksMetadata = await Promise.all(
+ wrappers.map((wrapper) => wrapper.task.getMetadata()),
+ );
+ res.status(200).json(tasksMetadata);
+ } else {
+ res.status(204).send();
+ }
+ } catch (error) {
+ logger.error('[CoreAgent] Error getting all task metadata:', error);
+ const errorMessage =
+ error instanceof Error
+ ? error.message
+ : 'Unknown error getting task metadata';
+ res.status(500).send({ error: errorMessage });
+ }
+ });
+
+ expressApp.get('/tasks/:taskId/metadata', async (req, res) => {
+ const taskId = req.params.taskId;
+ let wrapper = agentExecutor.getTask(taskId);
+ if (!wrapper) {
+ const sdkTask = await taskStoreForExecutor.load(taskId);
+ if (sdkTask) {
+ wrapper = await agentExecutor.reconstruct(sdkTask);
+ }
+ }
+ if (!wrapper) {
+ res.status(404).send({ error: 'Task not found' });
+ return;
+ }
+ res.json({ metadata: await wrapper.task.getMetadata() });
+ });
+ return expressApp;
+ } catch (error) {
+ logger.error('[CoreAgent] Error during startup:', error);
+ process.exit(1);
+ }
+}
+
+export async function main() {
+ try {
+ const expressApp = await createApp();
+ const port = Number(process.env['CODER_AGENT_PORT'] || 0);
+
+ const server = expressApp.listen(port, 'localhost', () => {
+ const address = server.address();
+ let actualPort;
+ if (process.env['CODER_AGENT_PORT']) {
+ actualPort = process.env['CODER_AGENT_PORT'];
+ } else if (address && typeof address !== 'string') {
+ actualPort = address.port;
+ } else {
+ throw new Error('[Core Agent] Could not find port number.');
+ }
+ updateCoderAgentCardUrl(Number(actualPort));
+ logger.info(
+ `[CoreAgent] Agent Server started on http://localhost:${actualPort}`,
+ );
+ logger.info(
+ `[CoreAgent] Agent Card: http://localhost:${actualPort}/.well-known/agent-card.json`,
+ );
+ logger.info('[CoreAgent] Press Ctrl+C to stop the server');
+ });
+ } catch (error) {
+ logger.error('[CoreAgent] Error during startup:', error);
+ process.exit(1);
+ }
+}
diff --git a/packages/a2a-server/src/http/endpoints.test.ts b/packages/a2a-server/src/http/endpoints.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..eec091f10af05ff329a3d8b23fc7f4a2e28b0b7a
--- /dev/null
+++ b/packages/a2a-server/src/http/endpoints.test.ts
@@ -0,0 +1,245 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ describe,
+ it,
+ expect,
+ beforeAll,
+ afterAll,
+ beforeEach,
+ afterEach,
+ vi,
+} from 'vitest';
+import request from 'supertest';
+import type express from 'express';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import type { Server } from 'node:http';
+import type { AddressInfo } from 'node:net';
+
+import { createApp, updateCoderAgentCardUrl } from './app.js';
+import type { TaskMetadata } from '../types.js';
+import { createMockConfig } from '../utils/testing_utils.js';
+import { debugLogger, type Config } from '@google/gemini-cli-core';
+import { logger } from '../utils/logger.js';
+
+// Mock the logger to avoid polluting test output
+// Comment out to help debug
+vi.mock('../utils/logger.js', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+
+vi.mock('../persistence/gcs.js', () => {
+ class MockGCSTaskStore {
+ load = vi.fn();
+ save = vi.fn();
+ }
+ class MockNoOpTaskStore {
+ constructor(public store: unknown) {}
+ }
+ return {
+ GCSTaskStore: MockGCSTaskStore,
+ NoOpTaskStore: MockNoOpTaskStore,
+ };
+});
+
+// Mock Task.create to avoid its complex setup
+vi.mock('../agent/task.js', () => {
+ class MockTask {
+ id: string;
+ contextId: string;
+ taskState = 'submitted';
+ config = {
+ getContentGeneratorConfig: vi
+ .fn()
+ .mockReturnValue({ model: 'gemini-pro' }),
+ };
+ geminiClient = {
+ initialize: vi.fn().mockResolvedValue(undefined),
+ };
+ constructor(id: string, contextId: string) {
+ this.id = id;
+ this.contextId = contextId;
+ }
+ static create = vi
+ .fn()
+ .mockImplementation((id, contextId) =>
+ Promise.resolve(new MockTask(id, contextId)),
+ );
+ getMetadata = vi.fn().mockImplementation(async () => ({
+ id: this.id,
+ contextId: this.contextId,
+ taskState: this.taskState,
+ model: 'gemini-pro',
+ mcpServers: [],
+ availableTools: [],
+ }));
+ }
+ return { Task: MockTask };
+});
+
+vi.mock('../config/config.js', async () => {
+ const actual = await vi.importActual('../config/config.js');
+ return {
+ ...actual,
+ loadConfig: vi
+ .fn()
+ .mockImplementation(async () => createMockConfig({}) as Config),
+ };
+});
+
+describe('Agent Server Endpoints', () => {
+ let app: express.Express;
+ let server: Server;
+ let testWorkspace: string;
+
+ const createTask = (contextId: string) =>
+ request(app)
+ .post('/tasks')
+ .send({
+ contextId,
+ agentSettings: {
+ kind: 'agent-settings',
+ workspacePath: testWorkspace,
+ },
+ })
+ .set('Content-Type', 'application/json');
+
+ beforeAll(async () => {
+ // Create a unique temporary directory for the workspace to avoid conflicts
+ testWorkspace = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'gemini-agent-test-'),
+ );
+ app = await createApp();
+ await new Promise((resolve) => {
+ server = app.listen(0, () => {
+ const port = (server.address() as AddressInfo).port;
+ updateCoderAgentCardUrl(port);
+ resolve();
+ });
+ });
+ });
+
+ afterAll(async () => {
+ if (server) {
+ await new Promise((resolve, reject) => {
+ server.close((err) => {
+ if (err) return reject(err);
+ resolve();
+ });
+ });
+ }
+
+ if (testWorkspace) {
+ try {
+ fs.rmSync(testWorkspace, { recursive: true, force: true });
+ } catch (e) {
+ debugLogger.warn(`Could not remove temp dir '${testWorkspace}':`, e);
+ }
+ }
+ });
+
+ it('should create a new task via POST /tasks', async () => {
+ const response = await createTask('test-context');
+ expect(response.status).toBe(201);
+ expect(response.body).toBeTypeOf('string'); // Should return the task ID
+ }, 7000);
+
+ it('should get metadata for a specific task via GET /tasks/:taskId/metadata', async () => {
+ const createResponse = await createTask('test-context-2');
+ const taskId = createResponse.body;
+ const response = await request(app).get(`/tasks/${taskId}/metadata`);
+ expect(response.status).toBe(200);
+ expect(response.body.metadata.id).toBe(taskId);
+ }, 6000);
+
+ it('should get metadata for all tasks via GET /tasks/metadata', async () => {
+ const createResponse = await createTask('test-context-3');
+ const taskId = createResponse.body;
+ const response = await request(app).get('/tasks/metadata');
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body.length).toBeGreaterThan(0);
+ const taskMetadata = response.body.find(
+ (m: TaskMetadata) => m.id === taskId,
+ );
+ expect(taskMetadata).toBeDefined();
+ });
+
+ it('should return 404 for a non-existent task', async () => {
+ const response = await request(app).get('/tasks/fake-task/metadata');
+ expect(response.status).toBe(404);
+ });
+
+ it('should return agent metadata via GET /.well-known/agent-card.json', async () => {
+ const response = await request(app).get('/.well-known/agent-card.json');
+ const port = (server.address() as AddressInfo).port;
+ expect(response.status).toBe(200);
+ expect(response.body.name).toBe('Gemini SDLC Agent');
+ expect(response.body.url).toBe(`http://localhost:${port}/`);
+ });
+
+ describe('GET /tasks/metadata with non-InMemoryTaskStore', () => {
+ let nonInMemoryApp: express.Express;
+ let nonInMemoryServer: Server;
+ let nonInMemoryWorkspace: string;
+ let originalCwd: string;
+
+ beforeEach(async () => {
+ originalCwd = process.cwd();
+ nonInMemoryWorkspace = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'gemini-agent-gcs-test-'),
+ );
+ vi.stubEnv('CODER_AGENT_WORKSPACE_PATH', nonInMemoryWorkspace);
+ vi.stubEnv('GCS_BUCKET_NAME', 'test-bucket');
+ nonInMemoryApp = await createApp();
+ await new Promise((resolve) => {
+ nonInMemoryServer = nonInMemoryApp.listen(0, () => {
+ resolve();
+ });
+ });
+ });
+
+ afterEach(async () => {
+ vi.unstubAllEnvs();
+ if (nonInMemoryServer) {
+ await new Promise((resolve, reject) => {
+ nonInMemoryServer.close((err) => {
+ if (err) return reject(err);
+ resolve();
+ });
+ });
+ }
+ if (originalCwd) {
+ process.chdir(originalCwd);
+ }
+ if (nonInMemoryWorkspace) {
+ try {
+ fs.rmSync(nonInMemoryWorkspace, { recursive: true, force: true });
+ } catch (e) {
+ debugLogger.warn(
+ `Could not remove temp dir '${nonInMemoryWorkspace}':`,
+ e,
+ );
+ }
+ }
+ });
+
+ it('should return 501 and not fall through to send a second response', async () => {
+ vi.mocked(logger.error).mockClear();
+ const response = await request(nonInMemoryApp).get('/tasks/metadata');
+ expect(response.status).toBe(501);
+ expect(response.body).toEqual({
+ error:
+ 'Listing all task metadata is only supported when using InMemoryTaskStore.',
+ });
+ // Without return, the handler falls through and triggers ERR_HTTP_HEADERS_SENT
+ expect(logger.error).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/a2a-server/src/http/requestStorage.ts b/packages/a2a-server/src/http/requestStorage.ts
new file mode 100644
index 0000000000000000000000000000000000000000..64aa13a046ff010cf6cc1e3d54eb0b58d6518a0b
--- /dev/null
+++ b/packages/a2a-server/src/http/requestStorage.ts
@@ -0,0 +1,10 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type express from 'express';
+import { AsyncLocalStorage } from 'node:async_hooks';
+
+export const requestStorage = new AsyncLocalStorage<{ req: express.Request }>();
diff --git a/packages/a2a-server/src/http/server.ts b/packages/a2a-server/src/http/server.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c22be4933125099c0894967e49d6f3a7c5cae29a
--- /dev/null
+++ b/packages/a2a-server/src/http/server.ts
@@ -0,0 +1,34 @@
+#!/usr/bin/env node
+
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as url from 'node:url';
+import * as path from 'node:path';
+
+import { logger } from '../utils/logger.js';
+import { main } from './app.js';
+
+// Check if the module is the main script being run
+const isMainModule =
+ path.basename(process.argv[1]) ===
+ path.basename(url.fileURLToPath(import.meta.url));
+
+if (
+ import.meta.url.startsWith('file:') &&
+ isMainModule &&
+ process.env['NODE_ENV'] !== 'test'
+) {
+ process.on('uncaughtException', (error) => {
+ logger.error('Unhandled exception:', error);
+ process.exit(1);
+ });
+
+ main().catch((error) => {
+ logger.error('[CoreAgent] Unhandled error in main:', error);
+ process.exit(1);
+ });
+}
diff --git a/packages/a2a-server/src/index.ts b/packages/a2a-server/src/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4db9a7ca92989638d52404258692c278328f6265
--- /dev/null
+++ b/packages/a2a-server/src/index.ts
@@ -0,0 +1,9 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export * from './agent/executor.js';
+export * from './http/app.js';
+export * from './types.js';
diff --git a/packages/a2a-server/src/persistence/gcs.test.ts b/packages/a2a-server/src/persistence/gcs.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..353a8312d573a7f79918addd2e89535e3c084b97
--- /dev/null
+++ b/packages/a2a-server/src/persistence/gcs.test.ts
@@ -0,0 +1,446 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Storage } from '@google-cloud/storage';
+import * as fse from 'fs-extra';
+import * as tar from 'tar';
+import { gzipSync, gunzipSync } from 'node:zlib';
+import { v4 as uuidv4 } from 'uuid';
+import type { Task as SDKTask } from '@a2a-js/sdk';
+import type { TaskStore } from '@a2a-js/sdk/server';
+import {
+ describe,
+ it,
+ expect,
+ beforeEach,
+ vi,
+ type Mocked,
+ type MockedClass,
+ type Mock,
+} from 'vitest';
+
+import { GCSTaskStore, NoOpTaskStore } from './gcs.js';
+import { logger } from '../utils/logger.js';
+import * as configModule from '../config/config.js';
+import { getPersistedState, METADATA_KEY } from '../types.js';
+
+// Mock dependencies
+const fsMocks = vi.hoisted(() => ({
+ readdir: vi.fn(),
+ createReadStream: vi.fn(),
+}));
+
+vi.mock('@google-cloud/storage');
+vi.mock('fs-extra', () => ({
+ pathExists: vi.fn(),
+ readdir: vi.fn(),
+ remove: vi.fn(),
+ ensureDir: vi.fn(),
+ createReadStream: vi.fn(),
+}));
+vi.mock('node:fs', async () => {
+ const actual = await vi.importActual('node:fs');
+ return {
+ ...actual,
+ promises: {
+ ...actual.promises,
+ readdir: fsMocks.readdir,
+ },
+ createReadStream: fsMocks.createReadStream,
+ };
+});
+vi.mock('fs', async () => {
+ const actual = await vi.importActual('node:fs');
+ return {
+ ...actual,
+ promises: {
+ ...actual.promises,
+ readdir: fsMocks.readdir,
+ },
+ createReadStream: fsMocks.createReadStream,
+ };
+});
+vi.mock('tar', async () => {
+ const actualFs = await vi.importActual('node:fs');
+ return {
+ c: vi.fn(({ file }) => {
+ if (file) {
+ actualFs.writeFileSync(file, Buffer.from('dummy tar content'));
+ }
+ return Promise.resolve();
+ }),
+ x: vi.fn().mockResolvedValue(undefined),
+ t: vi.fn().mockResolvedValue(undefined),
+ r: vi.fn().mockResolvedValue(undefined),
+ u: vi.fn().mockResolvedValue(undefined),
+ };
+});
+vi.mock('zlib');
+vi.mock('uuid');
+vi.mock('../utils/logger.js', () => ({
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ },
+}));
+vi.mock('../config/config.js', () => ({
+ setTargetDir: vi.fn(),
+}));
+vi.mock('node:stream/promises', () => ({
+ pipeline: vi.fn(),
+}));
+vi.mock('../types.js', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ getPersistedState: vi.fn(),
+ };
+});
+
+const mockStorage = Storage as MockedClass;
+const mockFse = fse as Mocked;
+const mockCreateReadStream = fsMocks.createReadStream;
+const mockTar = tar as Mocked;
+const mockGzipSync = gzipSync as Mock;
+const mockGunzipSync = gunzipSync as Mock;
+const mockUuidv4 = uuidv4 as Mock;
+const mockSetTargetDir = configModule.setTargetDir as Mock;
+const mockGetPersistedState = getPersistedState as Mock;
+const TEST_METADATA_KEY = METADATA_KEY || '__persistedState';
+
+type MockWriteStream = {
+ emit: Mock<(event: string, ...args: unknown[]) => boolean>;
+ removeListener: Mock<
+ (event: string, cb: (error?: Error | null) => void) => MockWriteStream
+ >;
+ once: Mock<
+ (event: string, cb: (error?: Error | null) => void) => MockWriteStream
+ >;
+ on: Mock<
+ (event: string, cb: (error?: Error | null) => void) => MockWriteStream
+ >;
+ destroy: Mock<() => void>;
+ write: Mock<(chunk: unknown, encoding?: unknown, cb?: unknown) => boolean>;
+ end: Mock<(cb?: unknown) => void>;
+ destroyed: boolean;
+};
+
+type MockFile = {
+ save: Mock<(data: Buffer | string) => Promise>;
+ download: Mock<() => Promise<[Buffer]>>;
+ exists: Mock<() => Promise<[boolean]>>;
+ createWriteStream: Mock<() => MockWriteStream>;
+};
+
+type MockBucket = {
+ exists: Mock<() => Promise<[boolean]>>;
+ file: Mock<(path: string) => MockFile>;
+ name: string;
+};
+
+type MockStorageInstance = {
+ bucket: Mock<(name: string) => MockBucket>;
+ getBuckets: Mock<() => Promise<[Array<{ name: string }>]>>;
+ createBucket: Mock<(name: string) => Promise<[MockBucket]>>;
+};
+
+describe('GCSTaskStore', () => {
+ let bucketName: string;
+ let mockBucket: MockBucket;
+ let mockFile: MockFile;
+ let mockWriteStream: MockWriteStream;
+ let mockStorageInstance: MockStorageInstance;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ bucketName = 'test-bucket';
+
+ mockWriteStream = {
+ emit: vi.fn().mockReturnValue(true),
+ removeListener: vi.fn().mockReturnValue(mockWriteStream),
+ on: vi.fn((event, cb) => {
+ if (event === 'finish') setTimeout(cb, 0); // Simulate async finish
+ return mockWriteStream;
+ }),
+ once: vi.fn((event, cb) => {
+ if (event === 'finish') setTimeout(cb, 0); // Simulate async finish return mockWriteStream;
+ }),
+ destroy: vi.fn(),
+ write: vi.fn().mockReturnValue(true),
+ end: vi.fn(),
+ destroyed: false,
+ };
+
+ mockFile = {
+ save: vi.fn().mockResolvedValue(undefined),
+ download: vi.fn().mockResolvedValue([Buffer.from('')]),
+ exists: vi.fn().mockResolvedValue([true]),
+ createWriteStream: vi.fn().mockReturnValue(mockWriteStream),
+ };
+
+ mockBucket = {
+ exists: vi.fn().mockResolvedValue([true]),
+ file: vi.fn().mockReturnValue(mockFile),
+ name: bucketName,
+ };
+
+ mockStorageInstance = {
+ bucket: vi.fn().mockReturnValue(mockBucket),
+ getBuckets: vi.fn().mockResolvedValue([[{ name: bucketName }]]),
+ createBucket: vi.fn().mockResolvedValue([mockBucket]),
+ };
+ mockStorage.mockReturnValue(mockStorageInstance as unknown as Storage);
+
+ mockUuidv4.mockReturnValue('test-uuid');
+ mockSetTargetDir.mockReturnValue('/tmp/workdir');
+ mockGetPersistedState.mockReturnValue({
+ _agentSettings: {},
+ _taskState: 'submitted',
+ });
+ (fse.pathExists as Mock).mockResolvedValue(true);
+ fsMocks.readdir.mockResolvedValue(['file1.txt']);
+ mockFse.remove.mockResolvedValue(undefined);
+ mockFse.ensureDir.mockResolvedValue(undefined);
+ mockGzipSync.mockReturnValue(Buffer.from('compressed'));
+ mockGunzipSync.mockReturnValue(Buffer.from('{}'));
+ mockCreateReadStream.mockReturnValue({ on: vi.fn(), pipe: vi.fn() });
+ mockFse.createReadStream.mockReturnValue({
+ on: vi.fn(),
+ pipe: vi.fn(),
+ } as unknown as import('node:fs').ReadStream);
+ });
+
+ describe('Constructor & Initialization', () => {
+ it('should initialize and check bucket existence', async () => {
+ const store = new GCSTaskStore(bucketName);
+ await store['ensureBucketInitialized']();
+ expect(mockStorage).toHaveBeenCalledTimes(1);
+ expect(mockStorageInstance.getBuckets).toHaveBeenCalled();
+ expect(logger.info).toHaveBeenCalledWith(
+ expect.stringContaining('Bucket test-bucket exists'),
+ );
+ });
+
+ it('should create bucket if it does not exist', async () => {
+ mockStorageInstance.getBuckets.mockResolvedValue([[]]);
+ const store = new GCSTaskStore(bucketName);
+ await store['ensureBucketInitialized']();
+ expect(mockStorageInstance.createBucket).toHaveBeenCalledWith(bucketName);
+ expect(logger.info).toHaveBeenCalledWith(
+ expect.stringContaining('Bucket test-bucket created successfully'),
+ );
+ });
+
+ it('should throw if bucket creation fails', async () => {
+ mockStorageInstance.getBuckets.mockResolvedValue([[]]);
+ mockStorageInstance.createBucket.mockRejectedValue(
+ new Error('Create failed'),
+ );
+ const store = new GCSTaskStore(bucketName);
+ await expect(store['ensureBucketInitialized']()).rejects.toThrow(
+ 'Failed to create GCS bucket test-bucket: Error: Create failed',
+ );
+ });
+ });
+
+ describe('save', () => {
+ const mockTask: SDKTask = {
+ id: 'task1',
+ contextId: 'ctx1',
+ kind: 'task',
+ status: { state: 'working' },
+ metadata: {},
+ };
+
+ it('should save metadata and workspace', async () => {
+ const store = new GCSTaskStore(bucketName);
+ await store.save(mockTask);
+
+ expect(mockFile.save).toHaveBeenCalledTimes(1);
+ expect(mockTar.c).toHaveBeenCalledTimes(1);
+ expect(mockFse.remove).toHaveBeenCalledTimes(1);
+ expect(logger.info).toHaveBeenCalledWith(
+ expect.stringContaining('metadata saved to GCS'),
+ );
+ expect(logger.info).toHaveBeenCalledWith(
+ expect.stringContaining('workspace saved to GCS'),
+ );
+ });
+
+ it('should handle tar creation failure', async () => {
+ mockFse.pathExists.mockImplementation(
+ async (path) =>
+ !path.toString().includes('task-task1-workspace-test-uuid.tar.gz'),
+ );
+ const store = new GCSTaskStore(bucketName);
+ await expect(store.save(mockTask)).rejects.toThrow(
+ 'tar.c command failed to create',
+ );
+ });
+
+ it('should throw an error if taskId contains path traversal sequences', async () => {
+ const store = new GCSTaskStore('test-bucket');
+ const maliciousTask: SDKTask = {
+ id: '../../../malicious-task',
+ metadata: {
+ _internal: {
+ agentSettings: {
+ cacheDir: '/tmp/cache',
+ dataDir: '/tmp/data',
+ logDir: '/tmp/logs',
+ tempDir: '/tmp/temp',
+ },
+ taskState: 'working',
+ },
+ },
+ kind: 'task',
+ status: {
+ state: 'working',
+ timestamp: new Date().toISOString(),
+ },
+ contextId: 'test-context',
+ history: [],
+ artifacts: [],
+ };
+ await expect(store.save(maliciousTask)).rejects.toThrow(
+ 'Invalid taskId: ../../../malicious-task',
+ );
+ });
+ });
+
+ describe('load', () => {
+ it('should load task metadata and workspace', async () => {
+ mockGunzipSync.mockReturnValue(
+ Buffer.from(
+ JSON.stringify({
+ [TEST_METADATA_KEY]: {
+ _agentSettings: {},
+ _taskState: 'submitted',
+ },
+ _contextId: 'ctx1',
+ }),
+ ),
+ );
+ mockFile.download.mockResolvedValue([Buffer.from('compressed metadata')]);
+ mockFile.download.mockResolvedValueOnce([
+ Buffer.from('compressed metadata'),
+ ]);
+ mockBucket.file = vi.fn((path) => {
+ const newMockFile = { ...mockFile };
+ if (path.includes('metadata')) {
+ newMockFile.download = vi
+ .fn()
+ .mockResolvedValue([Buffer.from('compressed metadata')]);
+ newMockFile.exists = vi.fn().mockResolvedValue([true]);
+ } else {
+ newMockFile.download = vi
+ .fn()
+ .mockResolvedValue([Buffer.from('compressed workspace')]);
+ newMockFile.exists = vi.fn().mockResolvedValue([true]);
+ }
+ return newMockFile;
+ });
+
+ const store = new GCSTaskStore(bucketName);
+ const task = await store.load('task1');
+
+ expect(task).toBeDefined();
+ expect(task?.id).toBe('task1');
+ expect(mockBucket.file).toHaveBeenCalledWith(
+ 'tasks/task1/metadata.tar.gz',
+ );
+ expect(mockBucket.file).toHaveBeenCalledWith(
+ 'tasks/task1/workspace.tar.gz',
+ );
+ expect(mockTar.x).toHaveBeenCalledTimes(1);
+ expect(mockFse.remove).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return undefined if metadata not found', async () => {
+ mockFile.exists.mockResolvedValue([false]);
+ const store = new GCSTaskStore(bucketName);
+ const task = await store.load('task1');
+ expect(task).toBeUndefined();
+ expect(mockBucket.file).toHaveBeenCalledWith(
+ 'tasks/task1/metadata.tar.gz',
+ );
+ });
+
+ it('should load metadata even if workspace not found', async () => {
+ mockGunzipSync.mockReturnValue(
+ Buffer.from(
+ JSON.stringify({
+ [TEST_METADATA_KEY]: {
+ _agentSettings: {},
+ _taskState: 'submitted',
+ },
+ _contextId: 'ctx1',
+ }),
+ ),
+ );
+
+ mockBucket.file = vi.fn((path) => {
+ const newMockFile = { ...mockFile };
+ if (path.includes('workspace.tar.gz')) {
+ newMockFile.exists = vi.fn().mockResolvedValue([false]);
+ } else {
+ newMockFile.exists = vi.fn().mockResolvedValue([true]);
+ newMockFile.download = vi
+ .fn()
+ .mockResolvedValue([Buffer.from('compressed metadata')]);
+ }
+ return newMockFile;
+ });
+
+ const store = new GCSTaskStore(bucketName);
+ const task = await store.load('task1');
+
+ expect(task).toBeDefined();
+ expect(mockTar.x).not.toHaveBeenCalled();
+ expect(logger.info).toHaveBeenCalledWith(
+ expect.stringContaining('workspace archive not found'),
+ );
+ });
+ });
+
+ it('should throw an error if taskId contains path traversal sequences', async () => {
+ const store = new GCSTaskStore('test-bucket');
+ const maliciousTaskId = '../../../malicious-task';
+ await expect(store.load(maliciousTaskId)).rejects.toThrow(
+ `Invalid taskId: ${maliciousTaskId}`,
+ );
+ });
+});
+
+describe('NoOpTaskStore', () => {
+ let realStore: TaskStore;
+ let noOpStore: NoOpTaskStore;
+
+ beforeEach(() => {
+ // Create a mock of the real store to delegate to
+ realStore = {
+ save: vi.fn(),
+ load: vi.fn().mockResolvedValue({ id: 'task-123' } as SDKTask),
+ };
+ noOpStore = new NoOpTaskStore(realStore);
+ });
+
+ it("should not call the real store's save method", async () => {
+ const mockTask: SDKTask = { id: 'test-task' } as SDKTask;
+ await noOpStore.save(mockTask);
+ expect(realStore.save).not.toHaveBeenCalled();
+ });
+
+ it('should delegate the load method to the real store', async () => {
+ const taskId = 'task-123';
+ const result = await noOpStore.load(taskId);
+ expect(realStore.load).toHaveBeenCalledWith(taskId);
+ expect(result).toBeDefined();
+ expect(result?.id).toBe(taskId);
+ });
+});
diff --git a/packages/a2a-server/src/persistence/gcs.ts b/packages/a2a-server/src/persistence/gcs.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9c339d47c37dab467828000aabe6e0ec50d5c475
--- /dev/null
+++ b/packages/a2a-server/src/persistence/gcs.ts
@@ -0,0 +1,319 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Storage } from '@google-cloud/storage';
+import { gzipSync, gunzipSync } from 'node:zlib';
+import * as tar from 'tar';
+import * as fse from 'fs-extra';
+import { promises as fsPromises, createReadStream } from 'node:fs';
+import { tmpdir } from '@google/gemini-cli-core';
+import { join } from 'node:path';
+import type { Task as SDKTask } from '@a2a-js/sdk';
+import type { TaskStore } from '@a2a-js/sdk/server';
+import { logger } from '../utils/logger.js';
+import { setTargetDir } from '../config/config.js';
+import { getPersistedState, type PersistedTaskMetadata } from '../types.js';
+import { v4 as uuidv4 } from 'uuid';
+
+type ObjectType = 'metadata' | 'workspace';
+
+const getTmpArchiveFilename = (taskId: string): string =>
+ `task-${taskId}-workspace-${uuidv4()}.tar.gz`;
+
+// Validate the taskId to prevent path traversal attacks by ensuring it only contains safe characters.
+const isTaskIdValid = (taskId: string): boolean => {
+ // Allow only alphanumeric characters, dashes, and underscores, and ensure it's not empty.
+ const validTaskIdRegex = /^[a-zA-Z0-9_-]+$/;
+ return validTaskIdRegex.test(taskId);
+};
+
+export class GCSTaskStore implements TaskStore {
+ private storage: Storage;
+ private bucketName: string;
+ private bucketInitialized: Promise;
+
+ constructor(bucketName: string) {
+ if (!bucketName) {
+ throw new Error('GCS bucket name is required.');
+ }
+ this.storage = new Storage();
+ this.bucketName = bucketName;
+ logger.info(`GCSTaskStore initializing with bucket: ${this.bucketName}`);
+ // Prerequisites: user account or service account must have storage admin IAM role
+ // and the bucket name must be unique.
+ this.bucketInitialized = this.initializeBucket();
+ }
+
+ private async initializeBucket(): Promise {
+ try {
+ const [buckets] = await this.storage.getBuckets();
+ const exists = buckets.some((bucket) => bucket.name === this.bucketName);
+
+ if (!exists) {
+ logger.info(
+ `Bucket ${this.bucketName} does not exist in the list. Attempting to create...`,
+ );
+ try {
+ await this.storage.createBucket(this.bucketName);
+ logger.info(`Bucket ${this.bucketName} created successfully.`);
+ } catch (createError) {
+ logger.info(
+ `Failed to create bucket ${this.bucketName}: ${createError}`,
+ );
+ throw new Error(
+ `Failed to create GCS bucket ${this.bucketName}: ${createError}`,
+ );
+ }
+ } else {
+ logger.info(`Bucket ${this.bucketName} exists.`);
+ }
+ } catch (error) {
+ logger.info(
+ `Error during bucket initialization for ${this.bucketName}: ${error}`,
+ );
+ throw new Error(
+ `Failed to initialize GCS bucket ${this.bucketName}: ${error}`,
+ );
+ }
+ }
+
+ private async ensureBucketInitialized(): Promise {
+ await this.bucketInitialized;
+ }
+
+ private getObjectPath(taskId: string, type: ObjectType): string {
+ if (!isTaskIdValid(taskId)) {
+ throw new Error(`Invalid taskId: ${taskId}`);
+ }
+ return `tasks/${taskId}/${type}.tar.gz`;
+ }
+
+ async save(task: SDKTask): Promise {
+ await this.ensureBucketInitialized();
+ const taskId = task.id;
+ const persistedState = getPersistedState(
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ task.metadata as PersistedTaskMetadata,
+ );
+
+ if (!persistedState) {
+ throw new Error(`Task ${taskId} is missing persisted state in metadata.`);
+ }
+ const workDir = process.cwd();
+
+ const metadataObjectPath = this.getObjectPath(taskId, 'metadata');
+ const workspaceObjectPath = this.getObjectPath(taskId, 'workspace');
+
+ const dataToStore = task.metadata;
+
+ try {
+ const jsonString = JSON.stringify(dataToStore);
+ const compressedMetadata = gzipSync(Buffer.from(jsonString));
+ const metadataFile = this.storage
+ .bucket(this.bucketName)
+ .file(metadataObjectPath);
+ await metadataFile.save(compressedMetadata, {
+ contentType: 'application/gzip',
+ });
+ logger.info(
+ `Task ${taskId} metadata saved to GCS: gs://${this.bucketName}/${metadataObjectPath}`,
+ );
+
+ if (await fse.pathExists(workDir)) {
+ const entries = await fsPromises.readdir(workDir);
+ if (entries.length > 0) {
+ const tmpArchiveFile = join(tmpdir(), getTmpArchiveFilename(taskId));
+ try {
+ await tar.c(
+ {
+ gzip: true,
+ file: tmpArchiveFile,
+ cwd: workDir,
+ portable: true,
+ },
+ entries,
+ );
+
+ if (!(await fse.pathExists(tmpArchiveFile))) {
+ throw new Error(
+ `tar.c command failed to create ${tmpArchiveFile}`,
+ );
+ }
+
+ const workspaceFile = this.storage
+ .bucket(this.bucketName)
+ .file(workspaceObjectPath);
+ const sourceStream = createReadStream(tmpArchiveFile);
+ const destStream = workspaceFile.createWriteStream({
+ contentType: 'application/gzip',
+ resumable: true,
+ });
+
+ await new Promise((resolve, reject) => {
+ sourceStream.on('error', (err) => {
+ logger.error(
+ `Error in source stream for ${tmpArchiveFile}:`,
+ err,
+ );
+ // Attempt to close destStream if source fails
+ if (!destStream.destroyed) {
+ destStream.destroy(err);
+ }
+ reject(err);
+ });
+
+ destStream.on('error', (err) => {
+ logger.error(
+ `Error in GCS dest stream for ${workspaceObjectPath}:`,
+ err,
+ );
+ reject(err);
+ });
+
+ destStream.on('finish', () => {
+ logger.info(
+ `GCS destStream finished for ${workspaceObjectPath}`,
+ );
+ resolve();
+ });
+
+ logger.info(
+ `Piping ${tmpArchiveFile} to GCS object ${workspaceObjectPath}`,
+ );
+ sourceStream.pipe(destStream);
+ });
+ logger.info(
+ `Task ${taskId} workspace saved to GCS: gs://${this.bucketName}/${workspaceObjectPath}`,
+ );
+ } catch (error) {
+ logger.error(
+ `Error during workspace save process for ${taskId}:`,
+ error,
+ );
+ throw error;
+ } finally {
+ logger.info(`Cleaning up temporary file: ${tmpArchiveFile}`);
+ try {
+ if (await fse.pathExists(tmpArchiveFile)) {
+ await fse.remove(tmpArchiveFile);
+ logger.info(
+ `Successfully removed temporary file: ${tmpArchiveFile}`,
+ );
+ } else {
+ logger.warn(
+ `Temporary file not found for cleanup: ${tmpArchiveFile}`,
+ );
+ }
+ } catch (removeError) {
+ logger.error(
+ `Error removing temporary file ${tmpArchiveFile}:`,
+ removeError,
+ );
+ }
+ }
+ } else {
+ logger.info(
+ `Workspace directory ${workDir} is empty, skipping workspace save for task ${taskId}.`,
+ );
+ }
+ } else {
+ logger.info(
+ `Workspace directory ${workDir} not found, skipping workspace save for task ${taskId}.`,
+ );
+ }
+ } catch (error) {
+ logger.error(`Failed to save task ${taskId} to GCS:`, error);
+ throw error;
+ }
+ }
+
+ async load(taskId: string): Promise {
+ await this.ensureBucketInitialized();
+ const metadataObjectPath = this.getObjectPath(taskId, 'metadata');
+ const workspaceObjectPath = this.getObjectPath(taskId, 'workspace');
+
+ try {
+ const metadataFile = this.storage
+ .bucket(this.bucketName)
+ .file(metadataObjectPath);
+ const [metadataExists] = await metadataFile.exists();
+ if (!metadataExists) {
+ logger.info(`Task ${taskId} metadata not found in GCS.`);
+ return undefined;
+ }
+ const [compressedMetadata] = await metadataFile.download();
+ const jsonData = gunzipSync(compressedMetadata).toString();
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const loadedMetadata = JSON.parse(jsonData);
+ logger.info(`Task ${taskId} metadata loaded from GCS.`);
+
+ const persistedState = getPersistedState(loadedMetadata);
+ if (!persistedState) {
+ throw new Error(
+ `Loaded metadata for task ${taskId} is missing internal persisted state.`,
+ );
+ }
+ const agentSettings = persistedState._agentSettings;
+
+ const workDir = await setTargetDir(agentSettings);
+ await fse.ensureDir(workDir);
+ const workspaceFile = this.storage
+ .bucket(this.bucketName)
+ .file(workspaceObjectPath);
+ const [workspaceExists] = await workspaceFile.exists();
+ if (workspaceExists) {
+ const tmpArchiveFile = join(tmpdir(), getTmpArchiveFilename(taskId));
+ try {
+ await workspaceFile.download({ destination: tmpArchiveFile });
+ await tar.x({ file: tmpArchiveFile, cwd: workDir });
+ logger.info(
+ `Task ${taskId} workspace restored from GCS to ${workDir}`,
+ );
+ } finally {
+ if (await fse.pathExists(tmpArchiveFile)) {
+ await fse.remove(tmpArchiveFile);
+ }
+ }
+ } else {
+ logger.info(`Task ${taskId} workspace archive not found in GCS.`);
+ }
+
+ return {
+ id: taskId,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ contextId: loadedMetadata._contextId || uuidv4(),
+ kind: 'task',
+ status: {
+ state: persistedState._taskState,
+ timestamp: new Date().toISOString(),
+ },
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ metadata: loadedMetadata,
+ history: [],
+ artifacts: [],
+ };
+ } catch (error) {
+ logger.error(`Failed to load task ${taskId} from GCS:`, error);
+ throw error;
+ }
+ }
+}
+
+export class NoOpTaskStore implements TaskStore {
+ constructor(private realStore: TaskStore) {}
+
+ async save(task: SDKTask): Promise {
+ logger.info(`[NoOpTaskStore] save called for task ${task.id} - IGNORED`);
+ return Promise.resolve();
+ }
+
+ async load(taskId: string): Promise {
+ logger.info(
+ `[NoOpTaskStore] load called for task ${taskId}, delegating to real store.`,
+ );
+ return this.realStore.load(taskId);
+ }
+}
diff --git a/packages/a2a-server/src/types.ts b/packages/a2a-server/src/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b6f39714015b0f72bab7c14734f30730c209b43a
--- /dev/null
+++ b/packages/a2a-server/src/types.ts
@@ -0,0 +1,190 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {
+ MCPServerStatus,
+ ToolConfirmationOutcome,
+} from '@google/gemini-cli-core';
+import type { TaskState } from '@a2a-js/sdk';
+
+// Interfaces and enums for the CoderAgent protocol.
+
+export enum CoderAgentEvent {
+ /**
+ * An event requesting one or more tool call confirmations.
+ */
+ ToolCallConfirmationEvent = 'tool-call-confirmation',
+ /**
+ * An event updating on the status of one or more tool calls.
+ */
+ ToolCallUpdateEvent = 'tool-call-update',
+ /**
+ * An event providing text updates on the task.
+ */
+ TextContentEvent = 'text-content',
+ /**
+ * An event that indicates a change in the task's execution state.
+ */
+ StateChangeEvent = 'state-change',
+ /**
+ * An user-sent event to initiate the agent.
+ */
+ StateAgentSettingsEvent = 'agent-settings',
+ /**
+ * An event that contains a thought from the agent.
+ */
+ ThoughtEvent = 'thought',
+ /**
+ * An event that contains citation from the agent.
+ */
+ CitationEvent = 'citation',
+}
+
+export interface AgentSettings {
+ kind: CoderAgentEvent.StateAgentSettingsEvent;
+ workspacePath: string;
+ autoExecute?: boolean;
+ isTrusted?: boolean;
+}
+
+export interface ToolCallConfirmation {
+ kind: CoderAgentEvent.ToolCallConfirmationEvent;
+}
+
+export interface ToolCallUpdate {
+ kind: CoderAgentEvent.ToolCallUpdateEvent;
+}
+
+export interface TextContent {
+ kind: CoderAgentEvent.TextContentEvent;
+}
+
+export interface StateChange {
+ kind: CoderAgentEvent.StateChangeEvent;
+}
+
+export interface Thought {
+ kind: CoderAgentEvent.ThoughtEvent;
+}
+
+export interface Citation {
+ kind: CoderAgentEvent.CitationEvent;
+}
+
+export type ThoughtSummary = {
+ subject: string;
+ description: string;
+};
+
+export interface ToolConfirmationResponse {
+ outcome: ToolConfirmationOutcome;
+ callId: string;
+}
+
+export type CoderAgentMessage =
+ | AgentSettings
+ | ToolCallConfirmation
+ | ToolCallUpdate
+ | TextContent
+ | StateChange
+ | Thought
+ | Citation;
+
+export interface TaskMetadata {
+ id: string;
+ contextId: string;
+ taskState: TaskState;
+ model: string;
+ mcpServers: Array<{
+ name: string;
+ status: MCPServerStatus;
+ tools: Array<{
+ name: string;
+ description: string;
+ parameterSchema: unknown;
+ }>;
+ }>;
+ availableTools: Array<{
+ name: string;
+ description: string;
+ parameterSchema: unknown;
+ }>;
+}
+
+export interface PersistedStateMetadata {
+ _agentSettings: AgentSettings;
+ _taskState: TaskState;
+}
+
+export type PersistedTaskMetadata = { [k: string]: unknown };
+
+export const METADATA_KEY = '__persistedState';
+
+function isAgentSettings(value: unknown): value is AgentSettings {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ 'kind' in value &&
+ value.kind === CoderAgentEvent.StateAgentSettingsEvent &&
+ 'workspacePath' in value &&
+ typeof value.workspacePath === 'string'
+ );
+}
+
+function isPersistedStateMetadata(
+ value: unknown,
+): value is PersistedStateMetadata {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ '_agentSettings' in value &&
+ '_taskState' in value &&
+ isAgentSettings(value._agentSettings)
+ );
+}
+
+export function getPersistedState(
+ metadata: PersistedTaskMetadata,
+): PersistedStateMetadata | undefined {
+ const state = metadata?.[METADATA_KEY];
+ if (isPersistedStateMetadata(state)) {
+ return state;
+ }
+ return undefined;
+}
+
+export function getContextIdFromMetadata(
+ metadata: PersistedTaskMetadata | undefined,
+): string | undefined {
+ if (!metadata) {
+ return undefined;
+ }
+ const contextId = metadata['_contextId'];
+ return typeof contextId === 'string' ? contextId : undefined;
+}
+
+export function getAgentSettingsFromMetadata(
+ metadata: PersistedTaskMetadata | undefined,
+): AgentSettings | undefined {
+ if (!metadata) {
+ return undefined;
+ }
+ const coderAgent = metadata['coderAgent'];
+ if (isAgentSettings(coderAgent)) {
+ return coderAgent;
+ }
+ return undefined;
+}
+
+export function setPersistedState(
+ metadata: PersistedTaskMetadata,
+ state: PersistedStateMetadata,
+): PersistedTaskMetadata {
+ return {
+ ...metadata,
+ [METADATA_KEY]: state,
+ };
+}
diff --git a/packages/a2a-server/src/utils/executor_utils.ts b/packages/a2a-server/src/utils/executor_utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cf635f88226e1fecd4962f64e98cc6f54d8654ef
--- /dev/null
+++ b/packages/a2a-server/src/utils/executor_utils.ts
@@ -0,0 +1,51 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { Message } from '@a2a-js/sdk';
+import type { ExecutionEventBus } from '@a2a-js/sdk/server';
+import { v4 as uuidv4 } from 'uuid';
+
+import { CoderAgentEvent, type StateChange } from '../types.js';
+
+export async function pushTaskStateFailed(
+ error: unknown,
+ eventBus: ExecutionEventBus,
+ taskId: string,
+ contextId: string,
+) {
+ const errorMessage =
+ error instanceof Error ? error.message : 'Agent execution error';
+ const stateChange: StateChange = {
+ kind: CoderAgentEvent.StateChangeEvent,
+ };
+ eventBus.publish({
+ kind: 'status-update',
+ taskId,
+ contextId,
+ status: {
+ state: 'failed',
+ message: {
+ kind: 'message',
+ role: 'agent',
+ parts: [
+ {
+ kind: 'text',
+ text: errorMessage,
+ },
+ ],
+ messageId: uuidv4(),
+ taskId,
+ contextId,
+ } as Message,
+ },
+ final: true,
+ metadata: {
+ coderAgent: stateChange,
+ model: 'unknown',
+ error: errorMessage,
+ },
+ });
+}
diff --git a/packages/a2a-server/src/utils/logger.ts b/packages/a2a-server/src/utils/logger.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8dca944b910ca4644c90cbec617803a5326aa416
--- /dev/null
+++ b/packages/a2a-server/src/utils/logger.ts
@@ -0,0 +1,28 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import winston from 'winston';
+
+const logger = winston.createLogger({
+ level: 'info',
+ format: winston.format.combine(
+ // First, add a timestamp to the log info object
+ winston.format.timestamp({
+ format: 'YYYY-MM-DD HH:mm:ss.SSS A', // Custom timestamp format
+ }),
+ // Here we define the custom output format
+ winston.format.printf((info) => {
+ const { level, timestamp, message, ...rest } = info;
+ return (
+ `[${level.toUpperCase()}] ${timestamp} -- ${message}` +
+ `${Object.keys(rest).length > 0 ? `\n${JSON.stringify(rest, null, 2)}` : ''}`
+ ); // Only print ...rest if present
+ }),
+ ),
+ transports: [new winston.transports.Console()],
+});
+
+export { logger };
diff --git a/packages/a2a-server/src/utils/path_utils.ts b/packages/a2a-server/src/utils/path_utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2bbfc95b0d74885559fe5530dff555302c11735d
--- /dev/null
+++ b/packages/a2a-server/src/utils/path_utils.ts
@@ -0,0 +1,62 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { resolveToRealPath, isSubpath } from '@google/gemini-cli-core';
+
+/**
+ * Validates a workspace path to prevent path traversal attacks.
+ *
+ * @param workspacePath The path to validate.
+ * @param allowedRoot The root directory the path must be within. Defaults to CWD.
+ * @returns The resolved, safe path.
+ * @throws An error if the path is invalid or outside the allowed root.
+ */
+export async function validateWorkspacePath(
+ workspacePath?: string,
+ allowedRoot: string = process.cwd(),
+): Promise {
+ const trimmedPath = workspacePath?.trim();
+ if (!trimmedPath) {
+ return resolveToRealPath(allowedRoot);
+ }
+
+ if (trimmedPath.includes('\0')) {
+ throw new Error('Security violation: Null byte detected in path.');
+ }
+
+ try {
+ const canonicalAllowedRoot = resolveToRealPath(allowedRoot);
+ const resolvedWorkspacePath = path.resolve(
+ canonicalAllowedRoot,
+ trimmedPath,
+ );
+ const canonicalWorkspacePath = resolveToRealPath(resolvedWorkspacePath);
+
+ // Check if the resolved path is within the allowed root directory
+ if (
+ canonicalWorkspacePath !== canonicalAllowedRoot &&
+ !isSubpath(canonicalAllowedRoot, canonicalWorkspacePath)
+ ) {
+ throw new Error(
+ `Security violation: The path "${trimmedPath}" is outside the allowed root directory.`,
+ );
+ }
+
+ const stats = await fs.promises.stat(canonicalWorkspacePath);
+ if (!stats.isDirectory()) {
+ throw new Error(`The path "${trimmedPath}" is not a directory.`);
+ }
+
+ return canonicalWorkspacePath;
+ } catch (e) {
+ if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
+ throw new Error(`The path "${trimmedPath}" does not exist.`);
+ }
+ throw e; // Re-throw other errors
+ }
+}
diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..15dfb685624662920ff6478130ab871c86467f10
--- /dev/null
+++ b/packages/a2a-server/src/utils/testing_utils.ts
@@ -0,0 +1,233 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as path from 'node:path';
+import type {
+ Task as SDKTask,
+ TaskStatusUpdateEvent,
+ SendStreamingMessageSuccessResponse,
+} from '@a2a-js/sdk';
+import {
+ ApprovalMode,
+ DEFAULT_GEMINI_MODEL,
+ DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
+ GeminiClient,
+ HookSystem,
+ type MessageBus,
+ PolicyDecision,
+ tmpdir,
+ type Config,
+ type Storage,
+ NoopSandboxManager,
+ type ToolRegistry,
+ type SandboxManager,
+} from '@google/gemini-cli-core';
+import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
+import { expect, vi } from 'vitest';
+
+export function createMockConfig(
+ overrides: Partial = {},
+): Partial {
+ const tmpDir = tmpdir();
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const mockConfig = {
+ get config() {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ return this as unknown as Config;
+ },
+ get toolRegistry() {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const config = this as unknown as Config;
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ return config.getToolRegistry?.() as unknown as ToolRegistry;
+ },
+ get messageBus() {
+ return (
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ (this as unknown as Config).getMessageBus?.() as unknown as MessageBus
+ );
+ },
+ get geminiClient() {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const config = this as unknown as Config;
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ return config.getGeminiClient?.() as unknown as GeminiClient;
+ },
+ getToolRegistry: vi.fn().mockReturnValue({
+ getTool: vi.fn(),
+ getAllToolNames: vi.fn().mockReturnValue([]),
+ getAllTools: vi.fn().mockReturnValue([]),
+ getToolsByServer: vi.fn().mockReturnValue([]),
+ }),
+ getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
+ getIdeMode: vi.fn().mockReturnValue(false),
+ isInteractive: () => true,
+ getAllowedTools: vi.fn().mockReturnValue([]),
+ getWorkspaceContext: vi.fn().mockReturnValue({
+ isPathWithinWorkspace: () => true,
+ }),
+ getTargetDir: () => tmpDir,
+ getCheckpointingEnabled: vi.fn().mockReturnValue(false),
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ storage: {
+ getProjectTempDir: () => tmpDir,
+ getProjectTempCheckpointsDir: () => path.join(tmpDir, 'checkpoints'),
+ } as Storage,
+ getTruncateToolOutputThreshold: () =>
+ DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
+ getActiveModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL),
+ getDebugMode: vi.fn().mockReturnValue(false),
+ getContentGeneratorConfig: vi.fn().mockReturnValue({ model: 'gemini-pro' }),
+ getModel: vi.fn().mockReturnValue('gemini-pro'),
+ getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
+ setFallbackModelHandler: vi.fn(),
+ initialize: vi.fn().mockResolvedValue(undefined),
+ getProxy: vi.fn().mockReturnValue(undefined),
+ getHistory: vi.fn().mockReturnValue([]),
+ getEmbeddingModel: vi.fn().mockReturnValue('text-embedding-004'),
+ getSessionId: vi.fn().mockReturnValue('test-session-id'),
+ getUserTier: vi.fn(),
+ getMessageBus: vi.fn(),
+ getPolicyEngine: vi.fn(),
+ getEnableExtensionReloading: vi.fn().mockReturnValue(false),
+ getEnableHooks: vi.fn().mockReturnValue(false),
+ getMcpClientManager: vi.fn().mockReturnValue({
+ getMcpServers: vi.fn().mockReturnValue({}),
+ }),
+ getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
+ getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
+ getGitService: vi.fn(),
+ validatePathAccess: vi.fn().mockReturnValue(undefined),
+ getShellExecutionConfig: vi.fn().mockReturnValue({
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ sandboxManager: new NoopSandboxManager() as unknown as SandboxManager,
+ sanitizationConfig: {
+ allowedEnvironmentVariables: [],
+ blockedEnvironmentVariables: [],
+ enableEnvironmentVariableRedaction: false,
+ },
+ }),
+ isContextManagementEnabled: vi.fn().mockReturnValue(false),
+ getContextManagementConfig: vi.fn().mockReturnValue({ enabled: false }),
+ getExperimentalGemma: vi.fn().mockReturnValue(false),
+ ...overrides,
+ } as unknown as Config;
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ (mockConfig as unknown as { config: Config; promptId: string }).promptId =
+ 'test-prompt-id';
+
+ mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus());
+ mockConfig.getHookSystem = vi
+ .fn()
+ .mockReturnValue(new HookSystem(mockConfig));
+
+ mockConfig.getGeminiClient = vi
+ .fn()
+ .mockReturnValue(new GeminiClient(mockConfig));
+
+ mockConfig.getPolicyEngine = vi.fn().mockReturnValue({
+ check: async () => {
+ const mode = mockConfig.getApprovalMode();
+ if (mode === ApprovalMode.YOLO) {
+ return { decision: PolicyDecision.ALLOW };
+ }
+ return { decision: PolicyDecision.ASK_USER };
+ },
+ });
+
+ return mockConfig;
+}
+
+export function createStreamMessageRequest(
+ text: string,
+ messageId: string,
+ taskId?: string,
+) {
+ const request: {
+ jsonrpc: string;
+ id: string;
+ method: string;
+ params: {
+ message: {
+ kind: string;
+ role: string;
+ parts: [{ kind: string; text: string }];
+ messageId: string;
+ };
+ metadata: {
+ coderAgent: {
+ kind: string;
+ workspacePath: string;
+ };
+ };
+ taskId?: string;
+ };
+ } = {
+ jsonrpc: '2.0',
+ id: '1',
+ method: 'message/stream',
+ params: {
+ message: {
+ kind: 'message',
+ role: 'user',
+ parts: [{ kind: 'text', text }],
+ messageId,
+ },
+ metadata: {
+ coderAgent: {
+ kind: 'agent-settings',
+ workspacePath: '/tmp',
+ },
+ },
+ },
+ };
+
+ if (taskId) {
+ request.params.taskId = taskId;
+ }
+
+ return request;
+}
+
+export function assertUniqueFinalEventIsLast(
+ events: SendStreamingMessageSuccessResponse[],
+) {
+ // Final event is input-required & final
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const finalEvent = events[events.length - 1].result as TaskStatusUpdateEvent;
+ expect(finalEvent.metadata?.['coderAgent']).toMatchObject({
+ kind: 'state-change',
+ });
+ expect(finalEvent.status?.state).toBe('input-required');
+ expect(finalEvent.final).toBe(true);
+
+ // There is only one event with final and its the last
+ expect(
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ events.filter((e) => (e.result as TaskStatusUpdateEvent).final).length,
+ ).toBe(1);
+ expect(
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ events.findIndex((e) => (e.result as TaskStatusUpdateEvent).final),
+ ).toBe(events.length - 1);
+}
+
+export function assertTaskCreationAndWorkingStatus(
+ events: SendStreamingMessageSuccessResponse[],
+) {
+ // Initial task creation event
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const taskEvent = events[0].result as SDKTask;
+ expect(taskEvent.kind).toBe('task');
+ expect(taskEvent.status.state).toBe('submitted');
+
+ // Status update: working
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const workingEvent = events[1].result as TaskStatusUpdateEvent;
+ expect(workingEvent.kind).toBe('status-update');
+ expect(workingEvent.status.state).toBe('working');
+}
diff --git a/packages/a2a-server/tsconfig.json b/packages/a2a-server/tsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..06e3256b971bf14e7ecb6914e061559cd41b162e
--- /dev/null
+++ b/packages/a2a-server/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "lib": ["DOM", "DOM.Iterable", "ES2023"],
+ "composite": true,
+ "types": ["node", "vitest/globals"]
+ },
+ "include": ["index.ts", "src/**/*.ts", "src/**/*.json"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/a2a-server/vitest.config.ts b/packages/a2a-server/vitest.config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a22fdc31ae0759c017f462fce6dadd1d71fd0e5d
--- /dev/null
+++ b/packages/a2a-server/vitest.config.ts
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+///
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)'],
+ exclude: ['**/node_modules/**', '**/dist/**'],
+ globals: true,
+ reporters: ['default', 'junit'],
+ silent: true,
+ outputFile: {
+ junit: 'junit.xml',
+ },
+ coverage: {
+ enabled: true,
+ provider: 'v8',
+ reportsDirectory: './coverage',
+ include: ['src/**/*'],
+ reporter: [
+ ['text', { file: 'full-text-summary.txt' }],
+ 'html',
+ 'json',
+ 'lcov',
+ 'cobertura',
+ ['json-summary', { outputFile: 'coverage-summary.json' }],
+ ],
+ },
+ poolOptions: {
+ threads: {
+ minThreads: 8,
+ maxThreads: 16,
+ },
+ },
+ server: {
+ deps: {
+ inline: [/@google\/gemini-cli-core/],
+ },
+ },
+ },
+});
diff --git a/packages/cli/GEMINI.md b/packages/cli/GEMINI.md
new file mode 100644
index 0000000000000000000000000000000000000000..8bad8f0721c636767b55fd38d958750bb88e269f
--- /dev/null
+++ b/packages/cli/GEMINI.md
@@ -0,0 +1,28 @@
+## React & Ink (CLI UI)
+
+- **Side Effects**: Use reducers for complex state transitions; avoid `setState`
+ triggers in callbacks.
+- Always fix react-hooks/exhaustive-deps lint errors by adding the missing
+ dependencies.
+- **Shortcuts**: only define keyboard shortcuts in
+ `packages/cli/src/ui/key/keyBindings.ts`
+- Do not implement any logic performing custom string measurement or string
+ truncation. Use Ink layout instead leveraging ResizeObserver as needed. When
+ using `ResizeObserver`, prefer the `useCallback` ref pattern (as seen in
+ `MaxSizedBox.tsx`) to ensure size measurements are captured as soon as the
+ element is available, avoiding potential rendering timing issues.
+- Avoid prop drilling when at all possible.
+
+## Testing
+
+- **Utilities**: Use `renderWithProviders` and `waitFor` from
+ `packages/cli/src/test-utils/`.
+- **Snapshots**: Use `toMatchSnapshot()` to verify Ink output.
+- **SVG Snapshots**: Use `await expect(renderResult).toMatchSvgSnapshot()` for
+ UI components whenever colors or detailed visual layout matter. SVG snapshots
+ capture styling accurately. Make sure to await the `waitUntilReady()` of the
+ render result before asserting. After updating SVG snapshots, always examine
+ the resulting `.svg` files (e.g. by reading their content or visually
+ inspecting them) to ensure the render and colors actually look as expected and
+ don't just contain an error message.
+- **Mocks**: Use mocks as sparingly as possible.
diff --git a/packages/cli/examples/ask-user-dialog-demo.tsx b/packages/cli/examples/ask-user-dialog-demo.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..aeb22b30f08dffb61d98657d5b7bee0f8fa350a3
--- /dev/null
+++ b/packages/cli/examples/ask-user-dialog-demo.tsx
@@ -0,0 +1,102 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useState } from 'react';
+import { render, Box, Text } from 'ink';
+import { AskUserDialog } from '../src/ui/components/AskUserDialog.js';
+import { KeypressProvider } from '../src/ui/contexts/KeypressContext.js';
+import { QuestionType, type Question } from '@google/gemini-cli-core';
+
+const DEMO_QUESTIONS: Question[] = [
+ {
+ question: 'What type of project are you building?',
+ header: 'Project Type',
+ options: [
+ { label: 'Web Application', description: 'React, Next.js, or similar' },
+ { label: 'CLI Tool', description: 'Command-line interface with Node.js' },
+ { label: 'Library', description: 'NPM package or shared utility' },
+ ],
+ multiSelect: false,
+ },
+ {
+ question: 'Which features should be enabled?',
+ header: 'Features',
+ options: [
+ { label: 'TypeScript', description: 'Add static typing' },
+ { label: 'ESLint', description: 'Add linting and formatting' },
+ { label: 'Unit Tests', description: 'Add Vitest setup' },
+ { label: 'CI/CD', description: 'Add GitHub Actions' },
+ ],
+ multiSelect: true,
+ },
+ {
+ question: 'What is the project name?',
+ header: 'Name',
+ type: QuestionType.TEXT,
+ placeholder: 'my-awesome-project',
+ },
+ {
+ question: 'Initialize git repository?',
+ header: 'Git',
+ type: QuestionType.YESNO,
+ },
+];
+
+const Demo = () => {
+ const [result, setResult] = useState(null);
+ const [cancelled, setCancelled] = useState(false);
+
+ if (cancelled) {
+ return (
+
+
+ Dialog was cancelled. Project initialization aborted.
+
+
+ );
+ }
+
+ if (result) {
+ return (
+
+
+ Success! Project Configuration:
+
+ {DEMO_QUESTIONS.map((q, i) => (
+
+ {q.header}:
+ {result[i] || '(not answered)'}
+
+ ))}
+
+ Press Ctrl+C to exit
+
+
+ );
+ }
+
+ return (
+
+
+
+ AskUserDialog Demo
+
+ setCancelled(true)}
+ />
+
+
+ );
+};
+
+render();
diff --git a/packages/cli/examples/scrollable-list-demo.tsx b/packages/cli/examples/scrollable-list-demo.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8d834f91050e8a5f45555e9b0391682ae95e4b81
--- /dev/null
+++ b/packages/cli/examples/scrollable-list-demo.tsx
@@ -0,0 +1,165 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useState, useEffect, useRef } from 'react';
+import { render, Box, Text, useInput, useStdout } from 'ink';
+import {
+ ScrollableList,
+ type ScrollableListRef,
+} from '../src/ui/components/shared/ScrollableList.js';
+import { ScrollProvider } from '../src/ui/contexts/ScrollProvider.js';
+import { MouseProvider } from '../src/ui/contexts/MouseContext.js';
+import { KeypressProvider } from '../src/ui/contexts/KeypressContext.js';
+import {
+ enableMouseEvents,
+ disableMouseEvents,
+} from '../src/ui/utils/mouse.js';
+
+interface Item {
+ id: string;
+ title: string;
+}
+
+const getLorem = (index: number) =>
+ Array(10)
+ .fill(null)
+ .map(() => 'lorem ipsum '.repeat((index % 3) + 1).trim())
+ .join('\n');
+
+const Demo = () => {
+ const { stdout } = useStdout();
+ const [size, setSize] = useState({
+ columns: stdout.columns,
+ rows: stdout.rows,
+ });
+
+ useEffect(() => {
+ const onResize = () => {
+ setSize({
+ columns: stdout.columns,
+ rows: stdout.rows,
+ });
+ };
+
+ stdout.on('resize', onResize);
+ return () => {
+ stdout.off('resize', onResize);
+ };
+ }, [stdout]);
+
+ const [items, setItems] = useState- (() =>
+ Array.from({ length: 1000 }, (_, i) => ({
+ id: String(i),
+ title: `Item ${i + 1}`,
+ })),
+ );
+
+ const listRef = useRef>(null);
+
+ useInput((input, key) => {
+ if (input === 'a' || input === 'A') {
+ setItems((prev) => [
+ ...prev,
+ { id: String(prev.length), title: `Item ${prev.length + 1}` },
+ ]);
+ }
+ if ((input === 'e' || input === 'E') && !key.ctrl) {
+ setItems((prev) => {
+ if (prev.length === 0) return prev;
+ const lastIndex = prev.length - 1;
+ const lastItem = prev[lastIndex]!;
+ const newItem = { ...lastItem, title: lastItem.title + 'e' };
+ return [...prev.slice(0, lastIndex), newItem];
+ });
+ }
+ if (key.ctrl && input === 'e') {
+ listRef.current?.scrollToEnd();
+ }
+ // Let Ink handle Ctrl+C via exitOnCtrlC (default true) or handle explicitly if needed.
+ // For alternate buffer, explicit handling is often safer for cleanup.
+ if (key.escape || (key.ctrl && input === 'c')) {
+ process.exit(0);
+ }
+ });
+
+ return (
+
+
+
+
+
+ Press 'A' to add an item. Press 'E' to edit
+ last item. Press 'Ctrl+E' to scroll to end. Press
+ 'Esc' to exit. Mouse wheel or Shift+Up/Down to scroll.
+
+
+ (
+
+
+ {item.title}
+
+
+ }
+ >
+ {item.title}
+
+ {getLorem(index)}
+
+ )}
+ estimatedItemHeight={() => 14}
+ keyExtractor={(item) => item.id}
+ hasFocus={true}
+ initialScrollIndex={Number.MAX_SAFE_INTEGER}
+ initialScrollOffsetInIndex={Number.MAX_SAFE_INTEGER}
+ />
+
+ Count: {items.length}
+
+
+
+
+ );
+};
+
+// Enable mouse reporting before rendering
+enableMouseEvents();
+
+// Ensure cleanup happens on exit
+process.on('exit', () => {
+ disableMouseEvents();
+});
+
+// Handle SIGINT explicitly to ensure cleanup runs if Ink doesn't catch it in time
+process.on('SIGINT', () => {
+ process.exit(0);
+});
+
+render(, { alternateBuffer: true });
diff --git a/packages/cli/index.ts b/packages/cli/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a1f86ee4a78aa9389f5e35ca9b7bb21c3aeb8c46
--- /dev/null
+++ b/packages/cli/index.ts
@@ -0,0 +1,193 @@
+#!/usr/bin/env node
+
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { spawn } from 'node:child_process';
+import os from 'node:os';
+import v8 from 'node:v8';
+import {
+ RELAUNCH_EXIT_CODE,
+ getSpawnConfig,
+ getScriptArgs,
+} from './src/utils/processUtils.js';
+
+// --- Global Entry Point ---
+
+// Suppress known race condition error in node-pty on Windows and Linux
+// Tracking bug: https://github.com/microsoft/node-pty/issues/827
+process.on('uncaughtException', (error) => {
+ if (error instanceof Error) {
+ const message = error.message || '';
+ const isPtyResizeError =
+ message === 'Cannot resize a pty that has already exited';
+ const isEbadfError =
+ message.includes('EBADF') ||
+ (error as { code?: string }).code === 'EBADF';
+ const isFromNodePty =
+ error.stack?.includes('node-pty') || error.stack?.includes('PtyResize');
+
+ if ((isPtyResizeError || isEbadfError) && isFromNodePty) {
+ // This error happens with node-pty when resizing a pty that has just exited.
+ // It is a race condition in node-pty that we cannot prevent, so we silence it.
+ return;
+ }
+ }
+
+ // For other errors, we rely on the default behavior, but since we attached a listener,
+ // we must manually replicate it.
+ if (error instanceof Error) {
+ process.stderr.write(error.stack + '\n');
+ } else {
+ process.stderr.write(String(error) + '\n');
+ }
+ process.exit(1);
+});
+
+async function getMemoryNodeArgs(): Promise {
+ let autoConfigureMemory = true;
+ try {
+ const { readFileSync } = await import('node:fs');
+ const { join } = await import('node:path');
+ // Respect GEMINI_CLI_HOME environment variable, falling back to os.homedir()
+ const baseDir =
+ process.env['GEMINI_CLI_HOME'] || join(os.homedir(), '.gemini');
+ const settingsPath = join(baseDir, 'settings.json');
+ const rawSettings = readFileSync(settingsPath, 'utf8');
+ const settings = JSON.parse(rawSettings);
+ if (settings?.advanced?.autoConfigureMemory === false) {
+ autoConfigureMemory = false;
+ }
+ } catch {
+ // ignore
+ }
+
+ if (autoConfigureMemory) {
+ const totalMemoryMB = os.totalmem() / (1024 * 1024);
+ const heapStats = v8.getHeapStatistics();
+ const currentMaxOldSpaceSizeMb = Math.floor(
+ heapStats.heap_size_limit / 1024 / 1024,
+ );
+ const targetMaxOldSpaceSizeInMB = Math.floor(totalMemoryMB * 0.5);
+
+ if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) {
+ return [`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`];
+ }
+ }
+
+ return [];
+}
+
+async function run() {
+ if (!process.env['GEMINI_CLI_NO_RELAUNCH'] && !process.env['SANDBOX']) {
+ // --- Lightweight Parent Process / Daemon ---
+ // We avoid importing heavy dependencies here to save ~1.5s of startup time.
+
+ const scriptArgs = getScriptArgs();
+ const memoryArgs = await getMemoryNodeArgs();
+ const { spawnArgs, env: newEnv } = getSpawnConfig(memoryArgs, scriptArgs);
+
+ let latestAdminSettings: unknown = undefined;
+
+ // Prevent the parent process from exiting prematurely on signals.
+ // The child process will receive the same signals and handle its own cleanup.
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
+ process.on(sig as NodeJS.Signals, () => {});
+ }
+
+ const runner = () => {
+ process.stdin.pause();
+
+ const child = spawn(process.execPath, spawnArgs, {
+ stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
+ env: newEnv,
+ });
+
+ if (latestAdminSettings) {
+ child.send({ type: 'admin-settings', settings: latestAdminSettings });
+ }
+
+ child.on('message', (msg: { type?: string; settings?: unknown }) => {
+ if (msg.type === 'admin-settings-update' && msg.settings) {
+ latestAdminSettings = msg.settings;
+ }
+ });
+
+ return new Promise((resolve) => {
+ child.on('error', (err) => {
+ process.stderr.write(
+ 'Error: Failed to start child process: ' + err.message + '\n',
+ );
+ resolve(1);
+ });
+ child.on('close', (code) => {
+ process.stdin.resume();
+ resolve(code ?? 1);
+ });
+ });
+ };
+
+ while (true) {
+ try {
+ const exitCode = await runner();
+ if (process.platform === 'android' || exitCode !== RELAUNCH_EXIT_CODE) {
+ process.exit(exitCode);
+ }
+ } catch (error: unknown) {
+ process.stdin.resume();
+ process.stderr.write(
+ `Fatal error: Failed to relaunch the CLI process.\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
+ );
+ process.exit(1);
+ }
+ }
+ } else {
+ // --- Heavy Child Process ---
+ // Now we can safely import everything.
+ const { main } = await import('./src/gemini.js');
+ const { FatalError, writeToStderr } = await import(
+ '@google/gemini-cli-core'
+ );
+ const { runExitCleanup } = await import('./src/utils/cleanup.js');
+
+ main().catch(async (error: unknown) => {
+ // Set a timeout to force exit if cleanup hangs
+ const cleanupTimeout = setTimeout(() => {
+ writeToStderr('Cleanup timed out, forcing exit...\n');
+ process.exit(1);
+ }, 5000);
+
+ try {
+ await runExitCleanup();
+ } catch (cleanupError: unknown) {
+ writeToStderr(
+ `Error during final cleanup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`,
+ );
+ } finally {
+ clearTimeout(cleanupTimeout);
+ }
+
+ if (error instanceof FatalError) {
+ let errorMessage = error.message;
+ if (!process.env['NO_COLOR']) {
+ errorMessage = `\x1b[31m${errorMessage}\x1b[0m`;
+ }
+ writeToStderr(errorMessage + '\n');
+ process.exit(error.exitCode);
+ }
+
+ writeToStderr('An unexpected critical error occurred:');
+ if (error instanceof Error) {
+ writeToStderr(error.stack + '\n');
+ } else {
+ writeToStderr(String(error) + '\n');
+ }
+ process.exit(1);
+ });
+ }
+}
+
+run();
diff --git a/packages/cli/package.json b/packages/cli/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..757977920d3e04238e15e573bd3724e0d89505a9
--- /dev/null
+++ b/packages/cli/package.json
@@ -0,0 +1,91 @@
+{
+ "name": "@google/gemini-cli",
+ "version": "0.62.0-nightly.20260915.gae28844fb",
+ "description": "Gemini CLI",
+ "license": "Apache-2.0",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/google-gemini/gemini-cli.git"
+ },
+ "type": "module",
+ "main": "dist/index.js",
+ "bin": {
+ "gemini": "dist/index.js"
+ },
+ "scripts": {
+ "build": "node ../../scripts/build_package.js",
+ "start": "node dist/index.js",
+ "debug": "node --inspect-brk dist/index.js",
+ "lint": "eslint . --ext .ts,.tsx",
+ "format": "prettier --write .",
+ "test": "vitest run",
+ "test:ci": "vitest run",
+ "posttest": "npm run build",
+ "typecheck": "tsc --noEmit"
+ },
+ "files": [
+ "dist"
+ ],
+ "config": {
+ "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.62.0-nightly.20260915.gae28844fb"
+ },
+ "dependencies": {
+ "@agentclientprotocol/sdk": "0.16.1",
+ "@google/gemini-cli-core": "file:../core",
+ "@google/genai": "1.30.0",
+ "@iarna/toml": "2.2.5",
+ "@modelcontextprotocol/sdk": "1.23.0",
+ "ansi-escapes": "7.3.0",
+ "ansi-regex": "6.2.2",
+ "chalk": "4.1.2",
+ "cli-spinners": "2.9.2",
+ "clipboardy": "5.2.0",
+ "color-convert": "2.0.1",
+ "command-exists": "1.2.9",
+ "comment-json": "4.2.5",
+ "diff": "8.0.3",
+ "dotenv": "17.1.0",
+ "extract-zip": "2.0.1",
+ "fzf": "0.5.2",
+ "glob": "12.0.0",
+ "highlight.js": "11.11.1",
+ "ink": "npm:@jrichman/ink@6.6.9",
+ "ink-gradient": "3.0.0",
+ "ink-spinner": "5.0.0",
+ "latest-version": "9.0.0",
+ "lowlight": "3.3.0",
+ "mnemonist": "0.40.3",
+ "open": "10.1.2",
+ "prompts": "2.4.2",
+ "proper-lockfile": "4.1.2",
+ "react": "19.2.4",
+ "shell-quote": "1.8.3",
+ "simple-git": "3.28.0",
+ "string-width": "8.1.0",
+ "strip-ansi": "7.1.0",
+ "strip-json-comments": "3.1.1",
+ "tar": "7.5.8",
+ "tinygradient": "1.1.5",
+ "undici": "7.10.0",
+ "ws": "8.16.0",
+ "yargs": "17.7.2",
+ "zod": "3.25.76"
+ },
+ "devDependencies": {
+ "@google/gemini-cli-test-utils": "file:../test-utils",
+ "@types/command-exists": "1.2.3",
+ "@types/hast": "3.0.4",
+ "@types/node": "20.11.24",
+ "@types/react": "19.2.0",
+ "@types/semver": "7.7.0",
+ "@types/shell-quote": "1.7.5",
+ "@types/ws": "8.5.10",
+ "@types/yargs": "17.0.32",
+ "@xterm/headless": "5.5.0",
+ "typescript": "5.8.3",
+ "vitest": "3.2.4"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
diff --git a/packages/cli/src/deferred.test.ts b/packages/cli/src/deferred.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0a50bef309ca428af5305dd1eb78dd509d941d55
--- /dev/null
+++ b/packages/cli/src/deferred.test.ts
@@ -0,0 +1,242 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ describe,
+ it,
+ expect,
+ vi,
+ beforeEach,
+ type MockInstance,
+} from 'vitest';
+import {
+ runDeferredCommand,
+ defer,
+ setDeferredCommand,
+ type DeferredCommand,
+} from './deferred.js';
+import { ExitCodes } from '@google/gemini-cli-core';
+import type { ArgumentsCamelCase, CommandModule } from 'yargs';
+import { createMockSettings } from './test-utils/settings.js';
+
+const { mockRunExitCleanup, mockCoreEvents } = vi.hoisted(() => ({
+ mockRunExitCleanup: vi.fn(),
+ mockCoreEvents: {
+ emitFeedback: vi.fn(),
+ },
+}));
+
+vi.mock('@google/gemini-cli-core', async () => {
+ const actual = await vi.importActual('@google/gemini-cli-core');
+ return {
+ ...actual,
+ coreEvents: mockCoreEvents,
+ };
+});
+
+vi.mock('./utils/cleanup.js', () => ({
+ runExitCleanup: mockRunExitCleanup,
+}));
+
+let mockExit: MockInstance;
+
+describe('deferred', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockExit = vi
+ .spyOn(process, 'exit')
+ .mockImplementation(() => undefined as never);
+ setDeferredCommand(undefined as unknown as DeferredCommand); // Reset deferred command
+ });
+
+ describe('runDeferredCommand', () => {
+ it('should do nothing if no deferred command is set', async () => {
+ await runDeferredCommand(createMockSettings().merged);
+ expect(mockCoreEvents.emitFeedback).not.toHaveBeenCalled();
+ expect(mockExit).not.toHaveBeenCalled();
+ });
+
+ it('should execute the deferred command if enabled', async () => {
+ const mockHandler = vi.fn();
+ setDeferredCommand({
+ handler: mockHandler,
+ argv: { _: [], $0: 'gemini' } as ArgumentsCamelCase,
+ commandName: 'mcp',
+ });
+
+ const settings = createMockSettings({
+ merged: { admin: { mcp: { enabled: true } } },
+ }).merged;
+ await runDeferredCommand(settings);
+ expect(mockHandler).toHaveBeenCalled();
+ expect(mockRunExitCleanup).toHaveBeenCalled();
+ expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
+ });
+
+ it('should exit with FATAL_CONFIG_ERROR if MCP is disabled', async () => {
+ setDeferredCommand({
+ handler: vi.fn(),
+ argv: {} as ArgumentsCamelCase,
+ commandName: 'mcp',
+ });
+
+ const settings = createMockSettings({
+ merged: { admin: { mcp: { enabled: false } } },
+ }).merged;
+ await runDeferredCommand(settings);
+
+ expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
+ 'error',
+ 'MCP is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
+ );
+ expect(mockRunExitCleanup).toHaveBeenCalled();
+ expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
+ });
+
+ it('should exit with FATAL_CONFIG_ERROR if extensions are disabled', async () => {
+ setDeferredCommand({
+ handler: vi.fn(),
+ argv: {} as ArgumentsCamelCase,
+ commandName: 'extensions',
+ });
+
+ const settings = createMockSettings({
+ merged: { admin: { extensions: { enabled: false } } },
+ }).merged;
+ await runDeferredCommand(settings);
+
+ expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
+ 'error',
+ 'Extensions is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
+ );
+ expect(mockRunExitCleanup).toHaveBeenCalled();
+ expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
+ });
+
+ it('should exit with FATAL_CONFIG_ERROR if skills are disabled', async () => {
+ setDeferredCommand({
+ handler: vi.fn(),
+ argv: {} as ArgumentsCamelCase,
+ commandName: 'skills',
+ });
+
+ const settings = createMockSettings({
+ merged: { admin: { skills: { enabled: false } } },
+ }).merged;
+ await runDeferredCommand(settings);
+
+ expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
+ 'error',
+ 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
+ );
+ expect(mockRunExitCleanup).toHaveBeenCalled();
+ expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
+ });
+
+ it('should execute if admin settings are undefined (default implicit enable)', async () => {
+ const mockHandler = vi.fn();
+ setDeferredCommand({
+ handler: mockHandler,
+ argv: {} as ArgumentsCamelCase,
+ commandName: 'mcp',
+ });
+
+ const settings = createMockSettings({}).merged; // No admin settings
+ await runDeferredCommand(settings);
+
+ expect(mockHandler).toHaveBeenCalled();
+ expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
+ });
+ });
+
+ describe('defer', () => {
+ it('should wrap a command module and defer execution', async () => {
+ const originalHandler = vi.fn();
+ const commandModule: CommandModule = {
+ command: 'test',
+ describe: 'test command',
+ handler: originalHandler,
+ };
+
+ const deferredModule = defer(commandModule);
+ expect(deferredModule.command).toBe(commandModule.command);
+
+ // Execute the wrapper handler
+ const argv = { _: [], $0: 'gemini' } as ArgumentsCamelCase;
+ await deferredModule.handler(argv);
+
+ // Should check that it set the deferred command, but didn't run original handler yet
+ expect(originalHandler).not.toHaveBeenCalled();
+
+ // Now manually run it to verify it captured correctly
+ await runDeferredCommand(createMockSettings().merged);
+ expect(originalHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ settings: expect.objectContaining({
+ admin: expect.objectContaining({
+ extensions: expect.objectContaining({ enabled: true }),
+ }),
+ }),
+ }),
+ );
+ expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
+ });
+
+ it('should use parentCommandName if provided', async () => {
+ const commandModule: CommandModule = {
+ command: 'subcommand',
+ describe: 'sub command',
+ handler: vi.fn(),
+ };
+
+ const deferredModule = defer(commandModule, 'parent');
+ await deferredModule.handler({} as ArgumentsCamelCase);
+
+ const deferredMcp = defer(commandModule, 'mcp');
+ await deferredMcp.handler({} as ArgumentsCamelCase);
+
+ const mcpSettings = createMockSettings({
+ merged: { admin: { mcp: { enabled: false } } },
+ }).merged;
+ await runDeferredCommand(mcpSettings);
+
+ expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
+ 'error',
+ 'MCP is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
+ );
+ });
+
+ it('should fallback to unknown if no parentCommandName is provided', async () => {
+ const mockHandler = vi.fn();
+ const commandModule: CommandModule = {
+ command: ['foo', 'infoo'],
+ describe: 'foo command',
+ handler: mockHandler,
+ };
+
+ const deferredModule = defer(commandModule);
+ await deferredModule.handler({} as ArgumentsCamelCase);
+
+ // Verify it runs even if all known commands are disabled,
+ // confirming it didn't capture 'mcp', 'extensions', or 'skills'
+ // and defaulted to 'unknown' (or something else safe).
+ const settings = createMockSettings({
+ merged: {
+ admin: {
+ mcp: { enabled: false },
+ extensions: { enabled: false },
+ skills: { enabled: false },
+ },
+ },
+ }).merged;
+
+ await runDeferredCommand(settings);
+
+ expect(mockHandler).toHaveBeenCalled();
+ expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
+ });
+ });
+});
diff --git a/packages/cli/src/deferred.ts b/packages/cli/src/deferred.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1864ec2cb56133013d46afeed6bb53f3f8ff386b
--- /dev/null
+++ b/packages/cli/src/deferred.ts
@@ -0,0 +1,99 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import type { ArgumentsCamelCase, CommandModule } from 'yargs';
+import {
+ coreEvents,
+ ExitCodes,
+ getAdminErrorMessage,
+} from '@google/gemini-cli-core';
+import { runExitCleanup } from './utils/cleanup.js';
+import type { MergedSettings } from './config/settings.js';
+import process from 'node:process';
+
+export interface DeferredCommand {
+ handler: (argv: ArgumentsCamelCase) => void | Promise;
+ argv: ArgumentsCamelCase;
+ commandName: string;
+}
+
+let deferredCommand: DeferredCommand | undefined;
+
+export function setDeferredCommand(command: DeferredCommand) {
+ deferredCommand = command;
+}
+
+export async function runDeferredCommand(settings: MergedSettings) {
+ if (!deferredCommand) {
+ return;
+ }
+
+ const adminSettings = settings.admin;
+ const commandName = deferredCommand.commandName;
+
+ if (commandName === 'mcp' && adminSettings?.mcp?.enabled === false) {
+ coreEvents.emitFeedback(
+ 'error',
+ getAdminErrorMessage('MCP', undefined /* config */),
+ );
+ await runExitCleanup();
+ process.exit(ExitCodes.FATAL_CONFIG_ERROR);
+ }
+
+ if (
+ commandName === 'extensions' &&
+ adminSettings?.extensions?.enabled === false
+ ) {
+ coreEvents.emitFeedback(
+ 'error',
+ getAdminErrorMessage('Extensions', undefined /* config */),
+ );
+ await runExitCleanup();
+ process.exit(ExitCodes.FATAL_CONFIG_ERROR);
+ }
+
+ if (commandName === 'skills' && adminSettings?.skills?.enabled === false) {
+ coreEvents.emitFeedback(
+ 'error',
+ getAdminErrorMessage('Agent skills', undefined /* config */),
+ );
+ await runExitCleanup();
+ process.exit(ExitCodes.FATAL_CONFIG_ERROR);
+ }
+
+ // Inject settings into argv
+ const argvWithSettings = {
+ ...deferredCommand.argv,
+ settings,
+ };
+
+ await deferredCommand.handler(argvWithSettings);
+ await runExitCleanup();
+ process.exit(ExitCodes.SUCCESS);
+}
+
+/**
+ * Wraps a command's handler to defer its execution.
+ * It stores the handler and arguments in a singleton `deferredCommand` variable.
+ */
+export function defer(
+ commandModule: CommandModule,
+ parentCommandName?: string,
+): CommandModule {
+ return {
+ ...commandModule,
+ handler: (argv: ArgumentsCamelCase) => {
+ setDeferredCommand({
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ handler: commandModule.handler as (
+ argv: ArgumentsCamelCase,
+ ) => void | Promise,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ argv: argv as unknown as ArgumentsCamelCase,
+ commandName: parentCommandName || 'unknown',
+ });
+ },
+ };
+}
diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4c7fec75a7263a6f9be52cf8672e61409eaa9b68
--- /dev/null
+++ b/packages/cli/src/gemini.test.tsx
@@ -0,0 +1,1843 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ describe,
+ it,
+ expect,
+ vi,
+ beforeEach,
+ afterEach,
+ type MockInstance,
+ type Mock,
+} from 'vitest';
+import {
+ main,
+ setupUnhandledRejectionHandler,
+ validateDnsResolutionOrder,
+ startInteractiveUI,
+ getNodeMemoryArgs,
+ resolveSessionId,
+} from './gemini.js';
+import {
+ loadCliConfig,
+ parseArguments,
+ type CliArgs,
+} from './config/config.js';
+import { loadSandboxConfig } from './config/sandboxConfig.js';
+import { createMockSandboxConfig } from '@google/gemini-cli-test-utils';
+import { terminalCapabilityManager } from './ui/utils/terminalCapabilityManager.js';
+import { start_sandbox } from './utils/sandbox.js';
+import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
+import os from 'node:os';
+import v8 from 'node:v8';
+import { loadSettings, type LoadedSettings } from './config/settings.js';
+import {
+ createMockConfig,
+ createMockSettings,
+} from './test-utils/mockConfig.js';
+import { appEvents, AppEvent } from './utils/events.js';
+import {
+ type Config,
+ type ResumedSessionData,
+ type StartupWarning,
+ type ConversationRecord,
+ WarningPriority,
+ debugLogger,
+ coreEvents,
+ AuthType,
+ ExitCodes,
+} from '@google/gemini-cli-core';
+import { act } from 'react';
+import { type InitializationResult } from './core/initializer.js';
+import { runNonInteractive } from './nonInteractiveCli.js';
+import { SessionSelector, SessionError } from './utils/sessionUtils.js';
+
+// Hoisted constants and mocks
+const performance = vi.hoisted(() => ({
+ now: vi.fn(),
+}));
+vi.stubGlobal('performance', performance);
+
+const runNonInteractiveSpy = vi.hoisted(() => vi.fn());
+vi.mock('./nonInteractiveCli.js', () => ({
+ runNonInteractive: runNonInteractiveSpy,
+}));
+
+const terminalNotificationMocks = vi.hoisted(() => ({
+ notifyViaTerminal: vi.fn().mockResolvedValue(true),
+ buildRunEventNotificationContent: vi.fn(() => ({
+ title: 'Session complete',
+ body: 'done',
+ subtitle: 'Run finished',
+ })),
+}));
+vi.mock('./utils/terminalNotifications.js', () => ({
+ notifyViaTerminal: terminalNotificationMocks.notifyViaTerminal,
+ buildRunEventNotificationContent:
+ terminalNotificationMocks.buildRunEventNotificationContent,
+}));
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ recordSlowRender: vi.fn(),
+ logUserPrompt: vi.fn(),
+ writeToStdout: vi.fn((...args) =>
+ process.stdout.write(
+ ...(args as Parameters),
+ ),
+ ),
+ patchStdio: vi.fn(() => () => {}),
+ createWorkingStdio: vi.fn(() => ({
+ stdout: {
+ write: vi.fn((...args) =>
+ process.stdout.write(
+ ...(args as Parameters),
+ ),
+ ),
+ columns: 80,
+ rows: 24,
+ on: vi.fn(),
+ removeListener: vi.fn(),
+ },
+ stderr: {
+ write: vi.fn(),
+ },
+ })),
+ enableMouseEvents: vi.fn(),
+ disableMouseEvents: vi.fn(),
+ enterAlternateScreen: vi.fn(),
+ disableLineWrapping: vi.fn(),
+ getVersion: vi.fn(() => Promise.resolve('1.0.0')),
+ startupProfiler: {
+ start: vi.fn(() => ({
+ end: vi.fn(),
+ })),
+ flush: vi.fn(),
+ },
+ ClearcutLogger: {
+ getInstance: vi.fn(() => ({
+ logStartSessionEvent: vi.fn().mockResolvedValue(undefined),
+ logEndSessionEvent: vi.fn().mockResolvedValue(undefined),
+ logUserPrompt: vi.fn(),
+ addDefaultFields: vi.fn((data) => data),
+ })),
+ clearInstance: vi.fn(),
+ },
+ coreEvents: {
+ // eslint-disable-next-line @typescript-eslint/no-misused-spread
+ ...actual.coreEvents,
+ emitFeedback: vi.fn(),
+ emitConsoleLog: vi.fn(),
+ listenerCount: vi.fn().mockReturnValue(0),
+ on: vi.fn(),
+ off: vi.fn(),
+ drainBacklogs: vi.fn(),
+ },
+ };
+});
+
+vi.mock('ink', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ render: vi.fn((_node, options) => {
+ if (options.alternateBuffer) {
+ options.stdout.write('\x1b[?7l');
+ }
+ // Simulate rendering time for recordSlowRender test
+ const start = performance.now();
+ const end = performance.now();
+ if (options.onRender) {
+ options.onRender({ renderTime: end - start });
+ }
+ return {
+ unmount: vi.fn(),
+ rerender: vi.fn(),
+ cleanup: vi.fn(),
+ waitUntilExit: vi.fn(),
+ };
+ }),
+ };
+});
+
+// Custom error to identify mock process.exit calls
+class MockProcessExitError extends Error {
+ constructor(readonly code?: string | number | null | undefined) {
+ super('PROCESS_EXIT_MOCKED');
+ this.name = 'MockProcessExitError';
+ }
+}
+
+// Mock dependencies
+vi.mock('./config/settings.js', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ loadSettings: vi.fn().mockImplementation(() => ({
+ merged: actual.getDefaultsFromSchema(),
+ workspace: { settings: {} },
+ errors: [],
+ })),
+ saveModelChange: vi.fn(),
+ getDefaultsFromSchema: actual.getDefaultsFromSchema,
+ };
+});
+
+vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({
+ terminalCapabilityManager: {
+ detectCapabilities: vi.fn(),
+ getTerminalBackgroundColor: vi.fn(),
+ },
+}));
+
+vi.mock('./config/config.js', () => ({
+ loadCliConfig: vi.fn().mockImplementation(async () => createMockConfig()),
+ parseArguments: vi.fn().mockResolvedValue({
+ enabled: true,
+ allowedPaths: [],
+ networkAccess: false,
+ }),
+ isDebugMode: vi.fn(() => false),
+ getRequestedWorktreeName: vi.fn(() => undefined),
+ getWorktreeArg: vi.fn(() => undefined),
+}));
+
+vi.mock('read-package-up', () => ({
+ readPackageUp: vi.fn().mockResolvedValue({
+ enabled: true,
+ allowedPaths: [],
+ networkAccess: false,
+ packageJson: { name: 'test-pkg', version: 'test-version' },
+ path: '/fake/path/package.json',
+ }),
+}));
+
+vi.mock('update-notifier', () => ({
+ default: vi.fn(() => ({
+ notify: vi.fn(),
+ })),
+}));
+
+vi.mock('./utils/events.js', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ appEvents: {
+ emit: vi.fn(),
+ },
+ };
+});
+
+import * as readStdinModule from './utils/readStdin.js';
+
+vi.mock('./utils/sandbox.js', () => ({
+ sandbox_command: vi.fn(() => ''), // Default to no sandbox command
+ start_sandbox: vi.fn(() => Promise.resolve()), // Mock as an async function that resolves
+}));
+
+vi.mock('./utils/relaunch.js', () => ({
+ relaunchAppInChildProcess: vi.fn().mockResolvedValue(undefined),
+ relaunchOnExitCode: vi.fn(async (fn) => {
+ await fn();
+ }),
+}));
+
+vi.mock('./config/sandboxConfig.js', () => ({
+ loadSandboxConfig: vi.fn().mockResolvedValue({
+ enabled: true,
+ allowedPaths: [],
+ networkAccess: false,
+ command: 'docker',
+ image: 'test-image',
+ }),
+}));
+
+vi.mock('./deferred.js', () => ({
+ runDeferredCommand: vi.fn().mockResolvedValue(undefined),
+ setDeferredCommand: vi.fn(),
+ defer: vi.fn((m) => m),
+}));
+
+vi.mock('./ui/utils/mouse.js', () => ({
+ enableMouseEvents: vi.fn(),
+ disableMouseEvents: vi.fn(),
+ isIncompleteMouseSequence: vi.fn(),
+}));
+
+vi.mock('./validateNonInterActiveAuth.js', () => ({
+ validateNonInteractiveAuth: vi.fn().mockResolvedValue('google'),
+}));
+
+vi.mock('./config/auth.js', () => ({
+ validateAuthMethod: vi.fn().mockResolvedValue(null),
+}));
+
+describe('gemini.tsx main function', () => {
+ let originalIsTTY: boolean | undefined;
+ let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
+ [];
+
+ beforeEach(() => {
+ // Store and clear sandbox-related env variables to ensure a consistent test environment
+ vi.stubEnv('GEMINI_SANDBOX', '');
+ vi.stubEnv('SANDBOX', '');
+ vi.stubEnv('SHPOOL_SESSION_NAME', '');
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
+
+ initialUnhandledRejectionListeners =
+ process.listeners('unhandledRejection');
+
+ originalIsTTY = process.stdin.isTTY;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (process.stdin as any).isTTY = true;
+ });
+
+ afterEach(() => {
+ const currentListeners = process.listeners('unhandledRejection');
+ currentListeners.forEach((listener) => {
+ if (!initialUnhandledRejectionListeners.includes(listener)) {
+ process.removeListener('unhandledRejection', listener);
+ }
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (process.stdin as any).isTTY = originalIsTTY;
+
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+ });
+
+ it('should suppress AbortError and not open debug console', async () => {
+ const debugLoggerErrorSpy = vi.spyOn(debugLogger, 'error');
+ const debugLoggerLogSpy = vi.spyOn(debugLogger, 'log');
+ const abortError = new DOMException(
+ 'The operation was aborted.',
+ 'AbortError',
+ );
+
+ setupUnhandledRejectionHandler();
+ process.emit('unhandledRejection', abortError, Promise.resolve());
+
+ await new Promise(process.nextTick);
+
+ expect(debugLoggerErrorSpy).not.toHaveBeenCalled();
+ expect(debugLoggerLogSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Suppressed unhandled AbortError'),
+ );
+ });
+
+ it('should log unhandled promise rejections and open debug console on first error', async () => {
+ const processExitSpy = vi
+ .spyOn(process, 'exit')
+ .mockImplementation((code) => {
+ throw new MockProcessExitError(code);
+ });
+ const appEventsMock = vi.mocked(appEvents);
+ const debugLoggerErrorSpy = vi.spyOn(debugLogger, 'error');
+ const rejectionError = new Error('Test unhandled rejection');
+
+ setupUnhandledRejectionHandler();
+ // Simulate an unhandled rejection.
+ // We are not using Promise.reject here as vitest will catch it.
+ // Instead we will dispatch the event manually.
+ process.emit('unhandledRejection', rejectionError, Promise.resolve());
+
+ // We need to wait for the rejection handler to be called.
+ await new Promise(process.nextTick);
+
+ expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvent.OpenDebugConsole);
+ expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Unhandled Promise Rejection'),
+ );
+ expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Please file a bug report using the /bug tool.'),
+ );
+
+ // Simulate a second rejection
+ const secondRejectionError = new Error('Second test unhandled rejection');
+ process.emit('unhandledRejection', secondRejectionError, Promise.resolve());
+ await new Promise(process.nextTick);
+
+ // Ensure emit was only called once for OpenDebugConsole
+ const openDebugConsoleCalls = appEventsMock.emit.mock.calls.filter(
+ (call) => call[0] === AppEvent.OpenDebugConsole,
+ );
+ expect(openDebugConsoleCalls.length).toBe(1);
+
+ // Avoid the process.exit error from being thrown.
+ processExitSpy.mockRestore();
+ });
+});
+
+describe('setWindowTitle', () => {
+ it('should set window title when hideWindowTitle is false', async () => {
+ // setWindowTitle is not exported, but we can test its effect if we had a way to call it.
+ // Since we can't easily call it directly without exporting it, we skip direct testing
+ // and rely on startInteractiveUI tests which call it.
+ });
+});
+
+describe('initializeOutputListenersAndFlush', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should flush backlogs and setup listeners if no listeners exist', async () => {
+ const { coreEvents } = await import('@google/gemini-cli-core');
+ const { initializeOutputListenersAndFlush } = await import('./gemini.js');
+
+ // Mock listenerCount to return 0
+ vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(0);
+ const drainSpy = vi.spyOn(coreEvents, 'drainBacklogs');
+
+ initializeOutputListenersAndFlush();
+
+ expect(drainSpy).toHaveBeenCalled();
+ // We can't easily check if listeners were added without access to the internal state of coreEvents,
+ // but we can verify that drainBacklogs was called.
+ });
+});
+
+describe('getNodeMemoryArgs', () => {
+ let osTotalMemSpy: MockInstance;
+ let v8GetHeapStatisticsSpy: MockInstance;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let originalConfig: any;
+
+ beforeEach(() => {
+ osTotalMemSpy = vi.spyOn(os, 'totalmem');
+ v8GetHeapStatisticsSpy = vi.spyOn(v8, 'getHeapStatistics');
+ delete process.env['GEMINI_CLI_NO_RELAUNCH'];
+
+ originalConfig = process.config;
+ Object.defineProperty(process, 'config', {
+ value: {
+ ...originalConfig,
+ variables: { ...originalConfig?.variables, v8_enable_sandbox: 1 },
+ },
+ configurable: true,
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ Object.defineProperty(process, 'config', {
+ value: originalConfig,
+ configurable: true,
+ });
+ });
+
+ it('should return empty array if GEMINI_CLI_NO_RELAUNCH is set', () => {
+ process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
+ expect(getNodeMemoryArgs(false)).toEqual([]);
+ });
+
+ it('should return empty array if current heap limit is sufficient', () => {
+ osTotalMemSpy.mockReturnValue(16 * 1024 * 1024 * 1024); // 16GB
+ v8GetHeapStatisticsSpy.mockReturnValue({
+ heap_size_limit: 8 * 1024 * 1024 * 1024, // 8GB
+ });
+ // Target is 50% of 16GB = 8GB. Current is 8GB. Relaunch needed for EPT size only.
+ expect(getNodeMemoryArgs(false)).toEqual([
+ '--max-external-pointer-table-size=268435456',
+ ]);
+ });
+
+ it('should return memory args if current heap limit is insufficient', () => {
+ osTotalMemSpy.mockReturnValue(16 * 1024 * 1024 * 1024); // 16GB
+ v8GetHeapStatisticsSpy.mockReturnValue({
+ heap_size_limit: 4 * 1024 * 1024 * 1024, // 4GB
+ });
+ // Target is 50% of 16GB = 8GB. Current is 4GB. Relaunch needed for both.
+ expect(getNodeMemoryArgs(false)).toEqual([
+ '--max-external-pointer-table-size=268435456',
+ '--max-old-space-size=8192',
+ ]);
+ });
+
+ it('should log debug info when isDebugMode is true', () => {
+ const debugSpy = vi.spyOn(debugLogger, 'debug');
+ osTotalMemSpy.mockReturnValue(16 * 1024 * 1024 * 1024);
+ v8GetHeapStatisticsSpy.mockReturnValue({
+ heap_size_limit: 4 * 1024 * 1024 * 1024,
+ });
+ getNodeMemoryArgs(true);
+ expect(debugSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Current heap size'),
+ );
+ expect(debugSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Need to relaunch with more memory'),
+ );
+ });
+});
+
+describe('gemini.tsx main function kitty protocol', () => {
+ let originalEnvNoRelaunch: string | undefined;
+ let originalIsTTY: boolean | undefined;
+ let originalIsRaw: boolean | undefined;
+ let setRawModeSpy: MockInstance<
+ (mode: boolean) => NodeJS.ReadStream & { fd: 0 }
+ >;
+
+ beforeEach(() => {
+ // Set no relaunch in tests since process spawning causing issues in tests
+ originalEnvNoRelaunch = process.env['GEMINI_CLI_NO_RELAUNCH'];
+ process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ if (!(process.stdin as any).setRawMode) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (process.stdin as any).setRawMode = vi.fn();
+ }
+ setRawModeSpy = vi.spyOn(process.stdin, 'setRawMode');
+
+ originalIsTTY = process.stdin.isTTY;
+ originalIsRaw = process.stdin.isRaw;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (process.stdin as any).isTTY = true;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (process.stdin as any).isRaw = false;
+ });
+
+ afterEach(() => {
+ // Restore original env variables
+ if (originalEnvNoRelaunch !== undefined) {
+ process.env['GEMINI_CLI_NO_RELAUNCH'] = originalEnvNoRelaunch;
+ } else {
+ delete process.env['GEMINI_CLI_NO_RELAUNCH'];
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (process.stdin as any).isTTY = originalIsTTY;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (process.stdin as any).isRaw = originalIsRaw;
+ vi.restoreAllMocks();
+ });
+
+ it('should call setRawMode and detectCapabilities when isInteractive is true', async () => {
+ vi.mocked(loadCliConfig).mockResolvedValue(
+ createMockConfig({
+ isInteractive: () => true,
+ getQuestion: () => '',
+ getSandbox: () => undefined,
+ }),
+ );
+ vi.mocked(loadSettings).mockReturnValue(
+ createMockSettings({
+ merged: {
+ advanced: {},
+ security: { auth: {} },
+ ui: {},
+ },
+ }),
+ );
+ vi.mocked(parseArguments).mockResolvedValue({
+ model: undefined,
+ sandbox: undefined,
+ debug: undefined,
+ prompt: undefined,
+ promptInteractive: undefined,
+ query: undefined,
+ yolo: undefined,
+ approvalMode: undefined,
+ policy: undefined,
+ adminPolicy: undefined,
+ allowedMcpServerNames: undefined,
+ allowedTools: undefined,
+ experimentalAcp: undefined,
+ extensions: undefined,
+ listExtensions: undefined,
+ includeDirectories: undefined,
+ screenReader: undefined,
+ useWriteTodos: undefined,
+ resume: undefined,
+ sessionId: undefined,
+ listSessions: undefined,
+ deleteSession: undefined,
+ outputFormat: undefined,
+ fakeResponses: undefined,
+ recordResponses: undefined,
+ rawOutput: undefined,
+ acceptRawOutputRisk: undefined,
+ isCommand: undefined,
+ skipTrust: undefined,
+ });
+
+ await act(async () => {
+ await main();
+ });
+
+ expect(setRawModeSpy).toHaveBeenCalledWith(true);
+ expect(terminalCapabilityManager.detectCapabilities).toHaveBeenCalledTimes(
+ 1,
+ );
+ });
+
+ it('should call process.stdin.resume when isInteractive is true to protect against implicit Node pause', async () => {
+ const resumeSpy = vi.spyOn(process.stdin, 'resume');
+ vi.mocked(loadCliConfig).mockResolvedValue(
+ createMockConfig({
+ isInteractive: () => true,
+ getQuestion: () => '',
+ getSandbox: () => undefined,
+ }),
+ );
+ vi.mocked(loadSettings).mockReturnValue(
+ createMockSettings({
+ merged: {
+ advanced: {},
+ security: { auth: {} },
+ ui: {},
+ },
+ }),
+ );
+ vi.mocked(parseArguments).mockResolvedValue({
+ model: undefined,
+ sandbox: undefined,
+ debug: undefined,
+ prompt: undefined,
+ promptInteractive: undefined,
+ query: undefined,
+ yolo: undefined,
+ approvalMode: undefined,
+ policy: undefined,
+ adminPolicy: undefined,
+ allowedMcpServerNames: undefined,
+ allowedTools: undefined,
+ experimentalAcp: undefined,
+ extensions: undefined,
+ listExtensions: undefined,
+ includeDirectories: undefined,
+ screenReader: undefined,
+ useWriteTodos: undefined,
+ resume: undefined,
+ sessionId: undefined,
+ listSessions: undefined,
+ deleteSession: undefined,
+ outputFormat: undefined,
+ fakeResponses: undefined,
+ recordResponses: undefined,
+ rawOutput: undefined,
+ acceptRawOutputRisk: undefined,
+ isCommand: undefined,
+ skipTrust: undefined,
+ });
+
+ await act(async () => {
+ await main();
+ });
+
+ expect(resumeSpy).toHaveBeenCalledTimes(1);
+ resumeSpy.mockRestore();
+ });
+
+ it.each([
+ { flag: 'listExtensions' },
+ { flag: 'listSessions' },
+ { flag: 'deleteSession', value: 'session-id' },
+ ])('should handle --$flag flag', async ({ flag, value }) => {
+ const { listSessions, deleteSession } = await import('./utils/sessions.js');
+ const processExitSpy = vi
+ .spyOn(process, 'exit')
+ .mockImplementation((code) => {
+ throw new MockProcessExitError(code);
+ });
+
+ vi.mocked(loadSettings).mockReturnValue(
+ createMockSettings({
+ merged: {
+ advanced: {},
+ security: { auth: {} },
+ ui: {},
+ },
+ workspace: { settings: {} },
+ setValue: vi.fn(),
+ forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
+ }),
+ );
+
+ vi.mocked(parseArguments).mockResolvedValue({
+ enabled: true,
+ allowedPaths: [],
+ networkAccess: false,
+ promptInteractive: false,
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
+
+ const mockConfig = createMockConfig({
+ isInteractive: () => false,
+ getQuestion: () => '',
+ getSandbox: () => undefined,
+ getListExtensions: () => flag === 'listExtensions',
+ getListSessions: () => flag === 'listSessions',
+ getDeleteSession: () => (flag === 'deleteSession' ? value : undefined),
+ getExtensions: () => [
+ {
+ name: 'ext1',
+ id: 'ext1',
+ version: '1.0.0',
+ isActive: true,
+ path: '/path/to/ext1',
+ contextFiles: [],
+ },
+ ],
+ });
+
+ vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
+ vi.mock('./utils/sessions.js', () => ({
+ listSessions: vi.fn(),
+ deleteSession: vi.fn(),
+ }));
+
+ const debugLoggerLogSpy = vi
+ .spyOn(debugLogger, 'log')
+ .mockImplementation(() => {});
+
+ process.env['GEMINI_API_KEY'] = 'test-key';
+ try {
+ await main();
+ } catch (e) {
+ if (!(e instanceof MockProcessExitError)) throw e;
+ } finally {
+ delete process.env['GEMINI_API_KEY'];
+ }
+
+ if (flag === 'listExtensions') {
+ expect(debugLoggerLogSpy).toHaveBeenCalledWith(
+ expect.stringContaining('ext1'),
+ );
+ } else if (flag === 'listSessions') {
+ expect(listSessions).toHaveBeenCalledWith(mockConfig);
+ } else if (flag === 'deleteSession') {
+ expect(deleteSession).toHaveBeenCalledWith(mockConfig, value);
+ }
+ expect(processExitSpy).toHaveBeenCalledWith(0);
+ processExitSpy.mockRestore();
+ });
+
+ it('should handle sandbox activation', async () => {
+ vi.stubEnv('SANDBOX', '');
+ const processExitSpy = vi
+ .spyOn(process, 'exit')
+ .mockImplementation((code) => {
+ throw new MockProcessExitError(code);
+ });
+
+ vi.mocked(parseArguments).mockResolvedValue({
+ enabled: true,
+ allowedPaths: [],
+ networkAccess: false,
+ promptInteractive: false,
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
+
+ vi.mocked(loadSettings).mockReturnValue(
+ createMockSettings({
+ merged: {
+ advanced: {},
+ security: { auth: { selectedType: 'google' } },
+ ui: {},
+ },
+ workspace: { settings: {} },
+ setValue: vi.fn(),
+ forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
+ }),
+ );
+
+ const mockConfig = createMockConfig({
+ isInteractive: () => false,
+ getQuestion: () => '',
+ getSandbox: () =>
+ createMockSandboxConfig({ command: 'docker', image: 'test-image' }),
+ });
+
+ vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
+ vi.mocked(loadSandboxConfig).mockResolvedValue(
+ createMockSandboxConfig({
+ command: 'docker',
+ image: 'test-image',
+ }),
+ );
+
+ process.env['GEMINI_API_KEY'] = 'test-key';
+ try {
+ await main();
+ } catch (e) {
+ if (!(e instanceof MockProcessExitError)) throw e;
+ } finally {
+ delete process.env['GEMINI_API_KEY'];
+ }
+
+ expect(start_sandbox).toHaveBeenCalled();
+ expect(processExitSpy).toHaveBeenCalledWith(0);
+ processExitSpy.mockRestore();
+ });
+
+ it('should log warning when theme is not found', async () => {
+ const { themeManager } = await import('./ui/themes/theme-manager.js');
+ const debugLoggerWarnSpy = vi
+ .spyOn(debugLogger, 'warn')
+ .mockImplementation(() => {});
+ const processExitSpy = vi
+ .spyOn(process, 'exit')
+ .mockImplementation((code) => {
+ throw new MockProcessExitError(code);
+ });
+
+ vi.mocked(loadSettings).mockReturnValue(
+ createMockSettings({
+ merged: {
+ advanced: {},
+ security: { auth: {} },
+ ui: { theme: 'non-existent-theme' },
+ },
+ workspace: { settings: {} },
+ setValue: vi.fn(),
+ forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
+ }),
+ );
+
+ vi.mocked(parseArguments).mockResolvedValue({
+ enabled: true,
+ allowedPaths: [],
+ networkAccess: false,
+ promptInteractive: false,
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
+ vi.mocked(loadCliConfig).mockResolvedValue(
+ createMockConfig({
+ isInteractive: () => false,
+ getQuestion: () => 'test',
+ getSandbox: () => undefined,
+ }),
+ );
+
+ vi.spyOn(themeManager, 'setActiveTheme').mockReturnValue(false);
+
+ process.env['GEMINI_API_KEY'] = 'test-key';
+ try {
+ await main();
+ } catch (e) {
+ if (!(e instanceof MockProcessExitError)) throw e;
+ } finally {
+ delete process.env['GEMINI_API_KEY'];
+ }
+
+ expect(debugLoggerWarnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Warning: Theme "non-existent-theme" not found.'),
+ );
+ processExitSpy.mockRestore();
+ });
+
+ it('should handle session selector error', async () => {
+ // eslint-disable-next-line prefer-arrow-callback
+ vi.mocked(SessionSelector).mockImplementation(function () {
+ return {
+ resolveSession: vi
+ .fn()
+ .mockRejectedValue(new Error('Session not found')),
+ } as unknown as InstanceType;
+ });
+
+ const processExitSpy = vi
+ .spyOn(process, 'exit')
+ .mockImplementation((code) => {
+ throw new MockProcessExitError(code);
+ });
+ const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
+
+ vi.mocked(loadSettings).mockReturnValue(
+ createMockSettings({
+ merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } },
+ workspace: { settings: {} },
+ setValue: vi.fn(),
+ forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
+ }),
+ );
+
+ vi.mocked(parseArguments).mockResolvedValue({
+ enabled: true,
+ allowedPaths: [],
+ networkAccess: false,
+ promptInteractive: false,
+ resume: 'session-id',
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
+ vi.mocked(loadCliConfig).mockResolvedValue(
+ createMockConfig({
+ isInteractive: () => true,
+ getQuestion: () => '',
+ getSandbox: () => undefined,
+ }),
+ );
+
+ try {
+ await main();
+ } catch (e) {
+ if (!(e instanceof MockProcessExitError)) throw e;
+ }
+
+ expect(emitFeedbackSpy).toHaveBeenCalledWith(
+ 'error',
+ expect.stringContaining('Error resuming session: Session not found'),
+ );
+ expect(processExitSpy).toHaveBeenCalledWith(42);
+ processExitSpy.mockRestore();
+ emitFeedbackSpy.mockRestore();
+ });
+
+ it('should start normally with a warning when no sessions found for resume', async () => {
+ // eslint-disable-next-line prefer-arrow-callback
+ vi.mocked(SessionSelector).mockImplementation(function () {
+ return {
+ resolveSession: vi
+ .fn()
+ .mockRejectedValue(SessionError.noSessionsFound()),
+ } as unknown as InstanceType;
+ });
+
+ const processExitSpy = vi
+ .spyOn(process, 'exit')
+ .mockImplementation((code) => {
+ throw new MockProcessExitError(code);
+ });
+ const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
+
+ vi.mocked(loadSettings).mockReturnValue(
+ createMockSettings({
+ merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } },
+ workspace: { settings: {} },
+ setValue: vi.fn(),
+ forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
+ }),
+ );
+
+ vi.mocked(parseArguments).mockResolvedValue({
+ enabled: true,
+ allowedPaths: [],
+ networkAccess: false,
+ promptInteractive: false,
+ resume: 'latest',
+ } as unknown as CliArgs);
+ vi.mocked(loadCliConfig).mockResolvedValue(
+ createMockConfig({
+ isInteractive: () => true,
+ getQuestion: () => '',
+ getSandbox: () => undefined,
+ }),
+ );
+
+ await main();
+
+ // Should NOT have crashed
+ expect(processExitSpy).not.toHaveBeenCalled();
+ // Should NOT have emitted a feedback error
+ expect(emitFeedbackSpy).not.toHaveBeenCalledWith(
+ 'error',
+ expect.stringContaining('Error resuming session'),
+ );
+ processExitSpy.mockRestore();
+ emitFeedbackSpy.mockRestore();
+ });
+
+ it.skip('should log error when cleanupExpiredSessions fails', async () => {
+ const { cleanupExpiredSessions } = await import(
+ './utils/sessionCleanup.js'
+ );
+ vi.mocked(cleanupExpiredSessions).mockRejectedValue(
+ new Error('Cleanup failed'),
+ );
+ const debugLoggerErrorSpy = vi
+ .spyOn(debugLogger, 'error')
+ .mockImplementation(() => {});
+ const processExitSpy = vi
+ .spyOn(process, 'exit')
+ .mockImplementation((code) => {
+ throw new MockProcessExitError(code);
+ });
+
+ vi.mocked(loadSettings).mockReturnValue(
+ createMockSettings({
+ merged: { advanced: {}, security: { auth: {} }, ui: {} },
+ workspace: { settings: {} },
+ setValue: vi.fn(),
+ forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
+ }),
+ );
+
+ vi.mocked(parseArguments).mockResolvedValue({
+ enabled: true,
+ allowedPaths: [],
+ networkAccess: false,
+ promptInteractive: false,
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
+ vi.mocked(loadCliConfig).mockResolvedValue(
+ createMockConfig({
+ isInteractive: () => false,
+ getQuestion: () => 'test',
+ getSandbox: () => undefined,
+ }),
+ );
+
+ // The mock is already set up at the top of the test
+
+ try {
+ await main();
+ } catch (e) {
+ if (!(e instanceof MockProcessExitError)) throw e;
+ }
+
+ expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'Failed to cleanup expired sessions: Cleanup failed',
+ ),
+ );
+ expect(processExitSpy).toHaveBeenCalledWith(0); // Should not exit on cleanup failure
+ processExitSpy.mockRestore();
+ });
+
+ it('should read from stdin in non-interactive mode', async () => {
+ vi.stubEnv('SANDBOX', 'true');
+ vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);
+ const processExitSpy = vi
+ .spyOn(process, 'exit')
+ .mockImplementation((code) => {
+ throw new MockProcessExitError(code);
+ });
+
+ const readStdinSpy = vi
+ .spyOn(readStdinModule, 'readStdin')
+ .mockResolvedValue('stdin-data');
+
+ vi.mocked(loadSettings).mockReturnValue(
+ createMockSettings({
+ merged: { advanced: {}, security: { auth: {} }, ui: {} },
+ workspace: { settings: {} },
+ setValue: vi.fn(),
+ forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
+ }),
+ );
+
+ vi.mocked(parseArguments).mockResolvedValue({
+ enabled: true,
+ allowedPaths: [],
+ networkAccess: false,
+ promptInteractive: false,
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
+ vi.mocked(loadCliConfig).mockResolvedValue(
+ createMockConfig({
+ isInteractive: () => false,
+ getQuestion: () => 'test-question',
+ getSandbox: () => undefined,
+ }),
+ );
+
+ // Mock stdin to be non-TTY
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (process.stdin as any).isTTY = false;
+
+ process.env['GEMINI_API_KEY'] = 'test-key';
+ try {
+ await main();
+ } catch (e) {
+ if (!(e instanceof MockProcessExitError)) throw e;
+ } finally {
+ delete process.env['GEMINI_API_KEY'];
+ }
+
+ expect(readStdinSpy).toHaveBeenCalled();
+ // In this test setup, runNonInteractive might be called on the mocked module,
+ // but we need to ensure we are checking the correct spy instance.
+ // Since vi.mock is hoisted, runNonInteractiveSpy is defined early.
+ expect(runNonInteractive).toHaveBeenCalled();
+ const callArgs = vi.mocked(runNonInteractive).mock.calls[0][0];
+ expect(callArgs.input).toBe('stdin-data\n\ntest-question');
+ expect(
+ terminalNotificationMocks.buildRunEventNotificationContent,
+ ).not.toHaveBeenCalled();
+ expect(terminalNotificationMocks.notifyViaTerminal).not.toHaveBeenCalled();
+ expect(processExitSpy).toHaveBeenCalledWith(0);
+ processExitSpy.mockRestore();
+ });
+});
+
+describe('resolveSessionId', () => {
+ it('should return a new session ID when neither resume nor sessionId is provided', async () => {
+ const { sessionId, resumedSessionData } = await resolveSessionId(
+ undefined,
+ undefined,
+ );
+ expect(sessionId).toBeDefined();
+ expect(resumedSessionData).toBeUndefined();
+ });
+
+ it('should import from session file when sessionFile is provided', async () => {
+ // eslint-disable-next-line prefer-arrow-callback
+ vi.mocked(SessionSelector).mockImplementation(function () {
+ return {
+ sessionExists: vi.fn().mockResolvedValue(false),
+ } as unknown as InstanceType;
+ });
+
+ const coreModule = await import('@google/gemini-cli-core');
+ vi.spyOn(coreModule, 'loadConversationRecord').mockResolvedValueOnce({
+ sessionId: 'old-session-id',
+ projectHash: 'hash',
+ startTime: 'time',
+ lastUpdated: 'time',
+ messages: [
+ { type: 'info', content: 'Old info', id: '1' },
+ { type: 'user', content: 'Hello', id: '2' },
+ { type: 'gemini', content: 'Hi', id: '3' },
+ { type: 'error', content: 'Old error', id: '4' },
+ { type: 'user', id: '5' }, // Missing content
+ null, // Null object
+ { type: 'unknown', content: 'Something', id: '6' }, // Unknown type
+ ],
+ } as unknown as ConversationRecord);
+
+ const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
+ const processExitSpy = vi
+ .spyOn(process, 'exit')
+ .mockImplementation((code) => {
+ throw new MockProcessExitError(code);
+ });
+
+ try {
+ const { sessionId, resumedSessionData } = await resolveSessionId(
+ undefined,
+ undefined,
+ 'dummy-session.json',
+ );
+
+ expect(sessionId).toBeDefined();
+ expect(sessionId).not.toBe('old-session-id'); // A new session ID should be created
+ expect(resumedSessionData).toBeDefined();
+ expect(resumedSessionData?.conversation.sessionId).toBe(sessionId); // Overwritten
+
+ // Verify messages: should have 1 info (the new import confirmation) + 2 valid conversation messages
+ // Invalid messages (missing content, null, unknown type) and transient messages should be filtered out.
+ expect(resumedSessionData?.conversation.messages).toHaveLength(3);
+ expect(resumedSessionData?.conversation.messages![0]).toMatchObject({
+ type: 'info',
+ content: expect.stringContaining('Imported session from'),
+ });
+ expect(resumedSessionData?.conversation.messages![1]).toMatchObject({
+ type: 'user',
+ content: 'Hello',
+ });
+ expect(resumedSessionData?.conversation.messages![2]).toMatchObject({
+ type: 'gemini',
+ content: 'Hi',
+ });
+
+ expect(resumedSessionData?.filePath).toContain(sessionId.slice(0, 8)); // New path
+ } catch (e) {
+ if (e instanceof MockProcessExitError) {
+ throw new Error(
+ 'process.exit called with: ' +
+ JSON.stringify(emitFeedbackSpy.mock.calls),
+ );
+ }
+ throw e;
+ } finally {
+ emitFeedbackSpy.mockRestore();
+ processExitSpy.mockRestore();
+ }
+ });
+
+ it('should exit with FATAL_INPUT_ERROR when sessionId already exists', async () => {
+ // eslint-disable-next-line prefer-arrow-callback
+ vi.mocked(SessionSelector).mockImplementation(function () {
+ return {
+ sessionExists: vi.fn().mockResolvedValue(true),
+ } as unknown as InstanceType;
+ });
+
+ const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
+ const processExitSpy = vi
+ .spyOn(process, 'exit')
+ .mockImplementation((code) => {
+ throw new MockProcessExitError(code);
+ });
+
+ try {
+ await resolveSessionId(undefined, 'existing-id');
+ } catch (e) {
+ if (!(e instanceof MockProcessExitError)) throw e;
+ }
+
+ expect(emitFeedbackSpy).toHaveBeenCalledWith(
+ 'error',
+ expect.stringContaining('Session ID "existing-id" already exists'),
+ );
+ expect(processExitSpy).toHaveBeenCalledWith(ExitCodes.FATAL_INPUT_ERROR);
+
+ emitFeedbackSpy.mockRestore();
+ processExitSpy.mockRestore();
+ });
+
+ it('should return provided sessionId when it does not exist', async () => {
+ // eslint-disable-next-line prefer-arrow-callback
+ vi.mocked(SessionSelector).mockImplementation(function () {
+ return {
+ sessionExists: vi.fn().mockResolvedValue(false),
+ } as unknown as InstanceType;
+ });
+ const { sessionId, resumedSessionData } = await resolveSessionId(
+ undefined,
+ 'new-id',
+ );
+ expect(sessionId).toBe('new-id');
+ expect(resumedSessionData).toBeUndefined();
+ });
+
+ it('should exit with FATAL_INPUT_ERROR when explicit resume session is missing', async () => {
+ vi.mocked(SessionSelector).mockImplementation(
+ () =>
+ ({
+ resolveSession: vi
+ .fn()
+ .mockRejectedValue(SessionError.noSessionsFound()),
+ }) as unknown as InstanceType,
+ );
+
+ const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
+ const processExitSpy = vi
+ .spyOn(process, 'exit')
+ .mockImplementation((code) => {
+ throw new MockProcessExitError(code);
+ });
+
+ try {
+ await resolveSessionId('explicit-session-id');
+ } catch (e) {
+ if (!(e instanceof MockProcessExitError)) throw e;
+ }
+
+ expect(emitFeedbackSpy).toHaveBeenCalledWith(
+ 'error',
+ expect.stringContaining('Error resuming session:'),
+ );
+ expect(processExitSpy).toHaveBeenCalledWith(ExitCodes.FATAL_INPUT_ERROR);
+
+ emitFeedbackSpy.mockRestore();
+ processExitSpy.mockRestore();
+ });
+});
+
+describe('gemini.tsx main function exit codes', () => {
+ let originalEnvNoRelaunch: string | undefined;
+ let originalIsTTY: boolean | undefined;
+
+ beforeEach(() => {
+ originalEnvNoRelaunch = process.env['GEMINI_CLI_NO_RELAUNCH'];
+ process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
+ vi.spyOn(process, 'exit').mockImplementation((code) => {
+ throw new MockProcessExitError(code);
+ });
+ // Mock stderr to avoid cluttering output
+ vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
+
+ originalIsTTY = process.stdin.isTTY;
+ });
+
+ afterEach(() => {
+ if (originalEnvNoRelaunch !== undefined) {
+ process.env['GEMINI_CLI_NO_RELAUNCH'] = originalEnvNoRelaunch;
+ } else {
+ delete process.env['GEMINI_CLI_NO_RELAUNCH'];
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (process.stdin as any).isTTY = originalIsTTY;
+ vi.restoreAllMocks();
+ });
+
+ it('should exit with 42 for invalid input combination (prompt-interactive with non-TTY)', async () => {
+ vi.mocked(loadCliConfig).mockResolvedValue(createMockConfig());
+ vi.mocked(loadSettings).mockReturnValue(
+ createMockSettings({
+ merged: { security: { auth: {} }, ui: {} },
+ }),
+ );
+ vi.mocked(parseArguments).mockResolvedValue({
+ enabled: true,
+ allowedPaths: [],
+ networkAccess: false,
+ promptInteractive: true,
+ } as unknown as CliArgs);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (process.stdin as any).isTTY = false;
+
+ try {
+ await main();
+ expect.fail('Should have thrown MockProcessExitError');
+ } catch (e) {
+ expect(e).toBeInstanceOf(MockProcessExitError);
+ expect((e as MockProcessExitError).code).toBe(42);
+ }
+ });
+
+ it('should exit with 41 for validateAuthMethod failure during sandbox setup', async () => {
+ vi.stubEnv('SANDBOX', '');
+ vi.mocked(loadSandboxConfig).mockResolvedValue(
+ createMockSandboxConfig({
+ command: 'docker',
+ image: 'test-image',
+ }),
+ );
+ vi.mocked(loadCliConfig).mockResolvedValue(
+ createMockConfig({
+ refreshAuth: vi.fn().mockResolvedValue(undefined),
+ getRemoteAdminSettings: vi.fn().mockReturnValue(undefined),
+ isInteractive: vi.fn().mockReturnValue(true),
+ }),
+ );
+ vi.mocked(loadSettings).mockReturnValue(
+ createMockSettings({
+ merged: {
+ security: { auth: { selectedType: 'google', useExternal: false } },
+ },
+ }),
+ );
+ vi.mocked(parseArguments).mockResolvedValue({} as CliArgs);
+
+ const authModule = await import('./config/auth.js');
+ vi.mocked(authModule.validateAuthMethod).mockResolvedValueOnce(
+ 'Auth method invalid',
+ );
+
+ try {
+ await main();
+ expect.fail('Should have thrown MockProcessExitError');
+ } catch (e) {
+ expect(e).toBeInstanceOf(MockProcessExitError);
+ expect((e as MockProcessExitError).code).toBe(41);
+ }
+ });
+
+ it('should exit with 41 for auth failure during sandbox setup', async () => {
+ vi.stubEnv('SANDBOX', '');
+ vi.mocked(loadSandboxConfig).mockResolvedValue(
+ createMockSandboxConfig({
+ command: 'docker',
+ image: 'test-image',
+ }),
+ );
+ vi.mocked(loadCliConfig).mockResolvedValue(
+ createMockConfig({
+ refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')),
+ getRemoteAdminSettings: vi.fn().mockReturnValue(undefined),
+ isInteractive: vi.fn().mockReturnValue(true),
+ }),
+ );
+ vi.mocked(loadSettings).mockReturnValue(
+ createMockSettings({
+ merged: {
+ security: { auth: { selectedType: 'google', useExternal: false } },
+ },
+ }),
+ );
+ vi.mocked(parseArguments).mockResolvedValue({} as CliArgs);
+
+ try {
+ await main();
+ expect.fail('Should have thrown MockProcessExitError');
+ } catch (e) {
+ expect(e).toBeInstanceOf(MockProcessExitError);
+ expect((e as MockProcessExitError).code).toBe(41);
+ }
+ });
+
+ it('should exit with 42 for session resume failure', async () => {
+ vi.mocked(loadCliConfig).mockResolvedValue(
+ createMockConfig({
+ isInteractive: () => false,
+ getQuestion: () => 'test',
+ getSandbox: () => undefined,
+ }),
+ );
+ vi.mocked(loadSettings).mockReturnValue(
+ createMockSettings({
+ merged: { security: { auth: {} }, ui: {} },
+ }),
+ );
+ vi.mocked(parseArguments).mockResolvedValue({
+ enabled: true,
+ allowedPaths: [],
+ networkAccess: false,
+ resume: 'invalid-session',
+ } as unknown as CliArgs);
+
+ vi.mock('./utils/sessionUtils.js', async (importOriginal) => {
+ const original =
+ await importOriginal();
+ return {
+ ...original,
+ SessionSelector: vi.fn().mockImplementation(() => ({
+ resolveSession: vi
+ .fn()
+ .mockRejectedValue(new Error('Session not found')),
+ })),
+ };
+ });
+
+ process.env['GEMINI_API_KEY'] = 'test-key';
+ try {
+ await main();
+ expect.fail('Should have thrown MockProcessExitError');
+ } catch (e) {
+ expect(e).toBeInstanceOf(MockProcessExitError);
+ expect((e as MockProcessExitError).code).toBe(42);
+ } finally {
+ delete process.env['GEMINI_API_KEY'];
+ }
+ });
+
+ it('should exit with 42 for no input provided', async () => {
+ vi.mocked(loadCliConfig).mockResolvedValue(
+ createMockConfig({
+ isInteractive: () => false,
+ getQuestion: () => '',
+ getSandbox: () => undefined,
+ }),
+ );
+ vi.mocked(loadSettings).mockReturnValue(
+ createMockSettings({
+ merged: { security: { auth: {} }, ui: {} },
+ }),
+ );
+ vi.mocked(parseArguments).mockResolvedValue({
+ enabled: true,
+ allowedPaths: [],
+ networkAccess: false,
+ } as unknown as CliArgs);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (process.stdin as any).isTTY = true;
+
+ process.env['GEMINI_API_KEY'] = 'test-key';
+ try {
+ await main();
+ expect.fail('Should have thrown MockProcessExitError');
+ } catch (e) {
+ expect(e).toBeInstanceOf(MockProcessExitError);
+ expect((e as MockProcessExitError).code).toBe(42);
+ } finally {
+ delete process.env['GEMINI_API_KEY'];
+ }
+ });
+
+ it('should validate and refresh auth in non-interactive mode when no auth type is selected but env var is present', async () => {
+ const refreshAuthSpy = vi.fn();
+ vi.mocked(loadCliConfig).mockResolvedValue(
+ createMockConfig({
+ isInteractive: () => false,
+ getQuestion: () => 'test prompt',
+ getSandbox: () => undefined,
+ refreshAuth: refreshAuthSpy,
+ }),
+ );
+ vi.mocked(validateNonInteractiveAuth).mockResolvedValue(
+ AuthType.USE_GEMINI,
+ );
+
+ vi.mocked(loadSettings).mockReturnValue(
+ createMockSettings({
+ merged: { security: { auth: { selectedType: undefined } }, ui: {} },
+ }),
+ );
+ vi.mocked(parseArguments).mockResolvedValue({
+ enabled: true,
+ allowedPaths: [],
+ networkAccess: false,
+ } as unknown as CliArgs);
+
+ runNonInteractiveSpy.mockImplementation(() => Promise.resolve());
+
+ const processExitSpy = vi
+ .spyOn(process, 'exit')
+ .mockImplementation((code) => {
+ throw new MockProcessExitError(code);
+ });
+
+ process.env['GEMINI_API_KEY'] = 'test-key';
+ try {
+ await main();
+ } catch (e) {
+ if (!(e instanceof MockProcessExitError)) throw e;
+ } finally {
+ delete process.env['GEMINI_API_KEY'];
+ processExitSpy.mockRestore();
+ }
+
+ expect(refreshAuthSpy).toHaveBeenCalledWith(AuthType.USE_GEMINI);
+ });
+});
+
+describe('validateDnsResolutionOrder', () => {
+ let debugLoggerWarnSpy: ReturnType;
+
+ beforeEach(() => {
+ debugLoggerWarnSpy = vi
+ .spyOn(debugLogger, 'warn')
+ .mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return "ipv4first" when the input is "ipv4first"', () => {
+ expect(validateDnsResolutionOrder('ipv4first')).toBe('ipv4first');
+ expect(debugLoggerWarnSpy).not.toHaveBeenCalled();
+ });
+
+ it('should return "verbatim" when the input is "verbatim"', () => {
+ expect(validateDnsResolutionOrder('verbatim')).toBe('verbatim');
+ expect(debugLoggerWarnSpy).not.toHaveBeenCalled();
+ });
+
+ it('should return the default "ipv4first" when the input is undefined', () => {
+ expect(validateDnsResolutionOrder(undefined)).toBe('ipv4first');
+ expect(debugLoggerWarnSpy).not.toHaveBeenCalled();
+ });
+
+ it('should return the default "ipv4first" and log a warning for an invalid string', () => {
+ expect(validateDnsResolutionOrder('invalid-value')).toBe('ipv4first');
+ expect(debugLoggerWarnSpy).toHaveBeenCalledExactlyOnceWith(
+ 'Invalid value for dnsResolutionOrder in settings: "invalid-value". Using default "ipv4first".',
+ );
+ });
+});
+
+describe('project hooks loading based on trust', () => {
+ let loadCliConfig: Mock;
+ let loadSettings: Mock;
+ let parseArguments: Mock;
+
+ beforeEach(async () => {
+ // Dynamically import and get the mocked functions
+ const configModule = await import('./config/config.js');
+ loadCliConfig = vi.mocked(configModule.loadCliConfig);
+ parseArguments = vi.mocked(configModule.parseArguments);
+ parseArguments.mockResolvedValue({
+ enabled: true,
+ allowedPaths: [],
+ networkAccess: false,
+ startupMessages: [],
+ });
+
+ const settingsModule = await import('./config/settings.js');
+ loadSettings = vi.mocked(settingsModule.loadSettings);
+
+ vi.clearAllMocks();
+ // Mock the main function's dependencies to isolate the config loading part
+ vi.mock('./nonInteractiveCli.js', () => ({
+ runNonInteractive: vi.fn().mockResolvedValue(undefined),
+ }));
+
+ vi.spyOn(process, 'exit').mockImplementation((() => {}) as unknown as (
+ code?: string | number | null,
+ ) => never);
+
+ // Default mock implementation for loadCliConfig
+ loadCliConfig.mockResolvedValue(
+ createMockConfig({
+ getQuestion: vi.fn().mockReturnValue('test question'),
+ }),
+ );
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should load project hooks when workspace is trusted', async () => {
+ const hooks = { 'before-model': 'echo "trusted"' };
+ loadSettings.mockReturnValue(
+ createMockSettings({
+ workspace: {
+ isTrusted: true,
+ settings: { hooks },
+ },
+ merged: {
+ security: { auth: { selectedType: 'google' } },
+ },
+ }),
+ );
+
+ await main();
+
+ expect(loadCliConfig).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ expect.anything(),
+ expect.objectContaining({
+ projectHooks: hooks,
+ }),
+ );
+ });
+
+ it('should NOT load project hooks when workspace is not trusted', async () => {
+ loadSettings.mockReturnValue(
+ createMockSettings({
+ workspace: {
+ isTrusted: false,
+ settings: {},
+ },
+ merged: {
+ security: { auth: { selectedType: 'google' } },
+ },
+ }),
+ );
+
+ await main();
+
+ expect(loadCliConfig).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ expect.anything(),
+ expect.objectContaining({
+ projectHooks: undefined,
+ }),
+ );
+ });
+});
+
+describe('startInteractiveUI', () => {
+ // Mock dependencies
+ const mockConfig = createMockConfig({
+ getProjectRoot: () => '/root',
+ getScreenReader: () => false,
+ getDebugMode: () => false,
+ getUseAlternateBuffer: () => true,
+ });
+ const mockSettings = {
+ merged: {
+ ui: {
+ hideWindowTitle: false,
+ useAlternateBuffer: true,
+ incrementalRendering: true,
+ },
+ general: {
+ debugKeystrokeLogging: false,
+ },
+ },
+ } as LoadedSettings;
+ const mockStartupWarnings: StartupWarning[] = [
+ { id: 'w1', message: 'warning1', priority: WarningPriority.High },
+ ];
+ const mockWorkspaceRoot = '/root';
+ const mockInitializationResult = {
+ authError: null,
+ accountSuspensionInfo: null,
+ themeError: null,
+ shouldOpenAuthDialog: false,
+ geminiMdFileCount: 0,
+ };
+
+ vi.mock('./ui/utils/updateCheck.js', () => ({
+ checkForUpdates: vi.fn(() => Promise.resolve(null)),
+ }));
+ vi.mock('./utils/cleanup.js', () => ({
+ cleanupCheckpoints: vi.fn(() => Promise.resolve()),
+ registerCleanup: vi.fn(),
+ removeCleanup: vi.fn(),
+ runExitCleanup: vi.fn(),
+ registerSyncCleanup: vi.fn(),
+ removeSyncCleanup: vi.fn(),
+ registerTelemetryConfig: vi.fn(),
+ setupSignalHandlers: vi.fn(),
+ setupTtyCheck: vi.fn(() => vi.fn()),
+ }));
+
+ beforeEach(() => {
+ vi.stubEnv('SHPOOL_SESSION_NAME', '');
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+ });
+
+ async function startTestInteractiveUI(
+ config: Config,
+ settings: LoadedSettings,
+ startupWarnings: StartupWarning[],
+ workspaceRoot: string,
+ resumedSessionData: ResumedSessionData | undefined,
+ initializationResult: InitializationResult,
+ ) {
+ await act(async () => {
+ await startInteractiveUI(
+ config,
+ settings,
+ startupWarnings,
+ workspaceRoot,
+ resumedSessionData,
+ initializationResult,
+ );
+ });
+ }
+
+ it('should render the UI with proper React context and exitOnCtrlC disabled', async () => {
+ const { render } = await import('ink');
+ const renderSpy = vi.mocked(render);
+
+ await startTestInteractiveUI(
+ mockConfig,
+ mockSettings,
+ mockStartupWarnings,
+ mockWorkspaceRoot,
+ undefined,
+ mockInitializationResult,
+ );
+
+ // Verify render was called with correct options
+ const [reactElement, options] = renderSpy.mock.calls[0];
+
+ // Verify render options
+ expect(options).toEqual(
+ expect.objectContaining({
+ alternateBuffer: true,
+ exitOnCtrlC: false,
+ incrementalRendering: true,
+ isScreenReaderEnabled: false,
+ onRender: expect.any(Function),
+ patchConsole: false,
+ }),
+ );
+
+ // Verify React element structure is valid (but don't deep dive into JSX internals)
+ expect(reactElement).toBeDefined();
+ });
+
+ it('should enable mouse events when alternate buffer is enabled', async () => {
+ const { enableMouseEvents } = await import('@google/gemini-cli-core');
+ await startTestInteractiveUI(
+ mockConfig,
+ mockSettings,
+ mockStartupWarnings,
+ mockWorkspaceRoot,
+ undefined,
+ mockInitializationResult,
+ );
+ expect(enableMouseEvents).toHaveBeenCalled();
+ });
+
+ it('should patch console', async () => {
+ const { ConsolePatcher } = await import('./ui/utils/ConsolePatcher.js');
+ const patchSpy = vi.spyOn(ConsolePatcher.prototype, 'patch');
+ await startTestInteractiveUI(
+ mockConfig,
+ mockSettings,
+ mockStartupWarnings,
+ mockWorkspaceRoot,
+ undefined,
+ mockInitializationResult,
+ );
+ expect(patchSpy).toHaveBeenCalled();
+ });
+
+ it('should perform all startup tasks in correct order', async () => {
+ const { getVersion } = await import('@google/gemini-cli-core');
+ const { checkForUpdates } = await import('./ui/utils/updateCheck.js');
+ const { registerCleanup } = await import('./utils/cleanup.js');
+
+ await startTestInteractiveUI(
+ mockConfig,
+ mockSettings,
+ mockStartupWarnings,
+ mockWorkspaceRoot,
+ undefined,
+ mockInitializationResult,
+ );
+
+ // Verify all startup tasks were called
+ expect(getVersion).toHaveBeenCalledTimes(1);
+ // 6 cleanups: mouseEvents, lineWrapping, non-resumable session cleanup,
+ // instance.unmount, TTY check, and consolePatcher
+ expect(registerCleanup).toHaveBeenCalledTimes(6);
+
+ // Verify cleanup handler is registered with unmount function
+ const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0];
+ expect(typeof cleanupFn).toBe('function');
+
+ // checkForUpdates should be called asynchronously (not waited for)
+ // We need a small delay to let it execute
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(checkForUpdates).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not recordSlowRender when less than threshold', async () => {
+ const { recordSlowRender } = await import('@google/gemini-cli-core');
+ performance.now.mockReturnValueOnce(0);
+ await startTestInteractiveUI(
+ mockConfig,
+ mockSettings,
+ mockStartupWarnings,
+ mockWorkspaceRoot,
+ undefined,
+ mockInitializationResult,
+ );
+
+ expect(recordSlowRender).not.toHaveBeenCalled();
+ });
+
+ it('should call recordSlowRender when more than threshold', async () => {
+ const { recordSlowRender } = await import('@google/gemini-cli-core');
+ performance.now.mockReturnValueOnce(0);
+ performance.now.mockReturnValueOnce(300);
+
+ await startTestInteractiveUI(
+ mockConfig,
+ mockSettings,
+ mockStartupWarnings,
+ mockWorkspaceRoot,
+ undefined,
+ mockInitializationResult,
+ );
+
+ expect(recordSlowRender).toHaveBeenCalledWith(mockConfig, 300);
+ });
+
+ it.each([
+ {
+ screenReader: true,
+ expectedCalls: [],
+ name: 'should not disable line wrapping in screen reader mode',
+ },
+ {
+ screenReader: false,
+ expectedCalls: [['\x1b[?7l']],
+ name: 'should disable line wrapping when not in screen reader mode',
+ },
+ ])('$name', async ({ screenReader, expectedCalls }) => {
+ const writeSpy = vi
+ .spyOn(process.stdout, 'write')
+ .mockImplementation(() => true);
+ const mockConfigWithScreenReader = {
+ // eslint-disable-next-line @typescript-eslint/no-misused-spread
+ ...mockConfig,
+ getScreenReader: () => screenReader,
+ } as Config;
+
+ await startTestInteractiveUI(
+ mockConfigWithScreenReader,
+ mockSettings,
+ mockStartupWarnings,
+ mockWorkspaceRoot,
+ undefined,
+ mockInitializationResult,
+ );
+
+ if (expectedCalls.length > 0) {
+ expect(writeSpy).toHaveBeenCalledWith(expectedCalls[0][0]);
+ } else {
+ expect(writeSpy).not.toHaveBeenCalledWith('\x1b[?7l');
+ }
+ writeSpy.mockRestore();
+ });
+});
diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f35429b952caddb4556538b653a2404057815305
--- /dev/null
+++ b/packages/cli/src/gemini.tsx
@@ -0,0 +1,973 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ type StartupWarning,
+ WarningPriority,
+ type Config,
+ type ResumedSessionData,
+ type WorktreeInfo,
+ type OutputPayload,
+ type ConsoleLogPayload,
+ type UserFeedbackPayload,
+ type CoreEvents,
+ createSessionId,
+ logUserPrompt,
+ AuthType,
+ UserPromptEvent,
+ coreEvents,
+ CoreEvent,
+ getOauthClient,
+ patchStdio,
+ writeToStdout,
+ writeToStderr,
+ shouldEnterAlternateScreen,
+ startupProfiler,
+ ExitCodes,
+ SessionStartSource,
+ SessionEndReason,
+ ValidationCancelledError,
+ ValidationRequiredError,
+ type AdminControlsSettings,
+ debugLogger,
+ isHeadlessMode,
+ Storage,
+ getProjectHash,
+ loadConversationRecord,
+ type MessageRecord,
+} from '@google/gemini-cli-core';
+
+import { loadCliConfig, parseArguments } from './config/config.js';
+import * as cliConfig from './config/config.js';
+import { readStdin } from './utils/readStdin.js';
+import { createHash } from 'node:crypto';
+import v8 from 'node:v8';
+import os from 'node:os';
+import dns from 'node:dns';
+import * as path from 'node:path';
+import * as fsPromises from 'node:fs/promises';
+import { start_sandbox } from './utils/sandbox.js';
+import {
+ loadSettings,
+ SettingScope,
+ type DnsResolutionOrder,
+ type LoadedSettings,
+} from './config/settings.js';
+import {
+ loadTrustedFolders,
+ type TrustedFoldersError,
+} from './config/trustedFolders.js';
+import { getStartupWarnings } from './utils/startupWarnings.js';
+import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
+import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
+import { runNonInteractive } from './nonInteractiveCli.js';
+import {
+ cleanupCheckpoints,
+ registerCleanup,
+ registerSyncCleanup,
+ runExitCleanup,
+ registerTelemetryConfig,
+ setupSignalHandlers,
+} from './utils/cleanup.js';
+import { setupWorktree } from './utils/worktreeSetup.js';
+import {
+ cleanupToolOutputFiles,
+ cleanupExpiredSessions,
+} from './utils/sessionCleanup.js';
+import {
+ initializeApp,
+ type InitializationResult,
+} from './core/initializer.js';
+import { validateAuthMethod } from './config/auth.js';
+import { runAcpClient } from './acp/acpStdioTransport.js';
+import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
+import { appEvents, AppEvent } from './utils/events.js';
+import {
+ RESUME_LATEST,
+ SessionError,
+ SessionSelector,
+} from './utils/sessionUtils.js';
+
+import { relaunchOnExitCode } from './utils/relaunch.js';
+import { loadSandboxConfig } from './config/sandboxConfig.js';
+import { deleteSession, listSessions } from './utils/sessions.js';
+import { createPolicyUpdater } from './config/policy.js';
+
+import { setupTerminalAndTheme } from './utils/terminalTheme.js';
+import { runDeferredCommand } from './deferred.js';
+import { cleanupBackgroundLogs } from './utils/logCleanup.js';
+import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js';
+
+export function validateDnsResolutionOrder(
+ order: string | undefined,
+): DnsResolutionOrder {
+ const defaultValue: DnsResolutionOrder = 'ipv4first';
+ if (order === undefined) {
+ return defaultValue;
+ }
+ if (order === 'ipv4first' || order === 'verbatim') {
+ return order;
+ }
+ // We don't want to throw here, just warn and use the default.
+ debugLogger.warn(
+ `Invalid value for dnsResolutionOrder in settings: "${order}". Using default "${defaultValue}".`,
+ );
+ return defaultValue;
+}
+
+const DEFAULT_EPT_SIZE = (256 * 1024 * 1024).toString();
+
+export function getNodeMemoryArgs(isDebugMode: boolean): string[] {
+ const totalMemoryMB = os.totalmem() / (1024 * 1024);
+ const heapStats = v8.getHeapStatistics();
+ const currentMaxOldSpaceSizeMb = Math.floor(
+ heapStats.heap_size_limit / 1024 / 1024,
+ );
+
+ // Set target to 50% of total memory
+ const targetMaxOldSpaceSizeInMB = Math.floor(totalMemoryMB * 0.5);
+ if (isDebugMode) {
+ debugLogger.debug(
+ `Current heap size ${currentMaxOldSpaceSizeMb.toFixed(2)} MB`,
+ );
+ }
+
+ if (process.env['GEMINI_CLI_NO_RELAUNCH']) {
+ return [];
+ }
+
+ const args: string[] = [];
+
+ // Automatically expand the V8 External Pointer Table to 256MB to prevent
+ // out-of-memory crashes during high native-handle concurrency.
+ // Note: Only supported in specific Node.js versions compiled with V8 Sandbox enabled.
+ const eptFlag = `--max-external-pointer-table-size=${DEFAULT_EPT_SIZE}`;
+ const isV8SandboxEnabled =
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
+ (process.config?.variables as any)?.v8_enable_sandbox === 1;
+
+ if (
+ isV8SandboxEnabled &&
+ !process.execArgv.some((arg) =>
+ arg.startsWith('--max-external-pointer-table-size'),
+ )
+ ) {
+ args.push(eptFlag);
+ }
+
+ if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) {
+ if (isDebugMode) {
+ debugLogger.debug(
+ `Need to relaunch with more memory: ${targetMaxOldSpaceSizeInMB.toFixed(2)} MB`,
+ );
+ }
+ args.push(`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`);
+ }
+
+ return args;
+}
+
+export function setupUnhandledRejectionHandler() {
+ let unhandledRejectionOccurred = false;
+ process.on('unhandledRejection', (reason, _promise) => {
+ // AbortError is expected when the user cancels a request (e.g. pressing ESC).
+ // It may surface as an unhandled rejection due to async timing in the
+ // streaming pipeline, but it is not a bug.
+ if (reason instanceof Error && reason.name === 'AbortError') {
+ debugLogger.log(`Suppressed unhandled AbortError: ${reason.message}`);
+ return;
+ }
+
+ const errorMessage = `=========================================
+This is an unexpected error. Please file a bug report using the /bug tool.
+CRITICAL: Unhandled Promise Rejection!
+=========================================
+Reason: ${reason}${
+ reason instanceof Error && reason.stack
+ ? `
+Stack trace:
+${reason.stack}`
+ : ''
+ }`;
+ debugLogger.error(errorMessage);
+ if (!unhandledRejectionOccurred) {
+ unhandledRejectionOccurred = true;
+ appEvents.emit(AppEvent.OpenDebugConsole);
+ }
+ });
+}
+
+export async function resolveSessionId(
+ resumeArg: string | undefined,
+ sessionIdArg?: string | undefined,
+ sessionFileArg?: string | undefined,
+): Promise<{
+ sessionId: string;
+ resumedSessionData?: ResumedSessionData;
+}> {
+ if (!resumeArg && !sessionIdArg && !sessionFileArg) {
+ return { sessionId: createSessionId() };
+ }
+
+ const storage = new Storage(process.cwd());
+ await storage.initialize();
+
+ const sessionSelector = new SessionSelector(storage);
+
+ if (sessionFileArg) {
+ try {
+ const sessionData = await loadConversationRecord(sessionFileArg);
+ if (!sessionData) {
+ throw new Error(`File not found or invalid format: ${sessionFileArg}`);
+ }
+
+ const now = Date.now();
+ const isoNow = new Date(now).toISOString();
+
+ // Filter out old system/info messages that are specific to the previous run
+ // and only keep actual conversation messages (user/gemini).
+ // Best effort parse: ensure message is an object and has required fields.
+ sessionData.messages = (sessionData.messages || []).filter(
+ (m) =>
+ typeof m === 'object' &&
+ m !== null &&
+ (m.type === 'user' || m.type === 'gemini') &&
+ m.content !== undefined,
+ );
+
+ // Add a single info message to the history to confirm the import
+ sessionData.messages.unshift({
+ id: `import-${now}`,
+ type: 'info',
+ content: `Imported session from ${sessionFileArg}`,
+ timestamp: isoNow,
+ } as MessageRecord);
+
+ const newSessionId = createSessionId();
+ sessionData.sessionId = newSessionId;
+ sessionData.projectHash = getProjectHash(storage.getProjectRoot());
+ sessionData.startTime = isoNow;
+ sessionData.lastUpdated = isoNow;
+
+ const chatsDir = path.join(storage.getProjectTempDir(), 'chats');
+ const newSessionPath = path.join(
+ chatsDir,
+ `session-${now}-${newSessionId.slice(0, 8)}.jsonl`,
+ );
+
+ const { messages: _messages, ...initialMetadata } = sessionData;
+
+ const lines = [JSON.stringify(initialMetadata)];
+ if (sessionData.messages) {
+ for (const msg of sessionData.messages) {
+ lines.push(JSON.stringify(msg));
+ }
+ }
+
+ await fsPromises.mkdir(chatsDir, { recursive: true });
+ await fsPromises.writeFile(
+ newSessionPath,
+ lines.join('\n') + '\n',
+ 'utf-8',
+ );
+
+ return {
+ sessionId: newSessionId,
+ resumedSessionData: {
+ conversation: sessionData,
+ filePath: newSessionPath,
+ },
+ };
+ } catch (error) {
+ coreEvents.emitFeedback(
+ 'error',
+ `Error importing session from file: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ );
+ await runExitCleanup();
+ process.exit(ExitCodes.FATAL_INPUT_ERROR);
+ }
+ }
+
+ if (sessionIdArg) {
+ if (await sessionSelector.sessionExists(sessionIdArg)) {
+ coreEvents.emitFeedback(
+ 'error',
+ `Error starting session: Session ID "${sessionIdArg}" already exists. Use --resume to resume it, or provide a different ID.`,
+ );
+ await runExitCleanup();
+ process.exit(ExitCodes.FATAL_INPUT_ERROR);
+ }
+ return { sessionId: sessionIdArg };
+ }
+
+ try {
+ const { sessionData, sessionPath } = await sessionSelector.resolveSession(
+ resumeArg!,
+ );
+ return {
+ sessionId: sessionData.sessionId,
+ resumedSessionData: { conversation: sessionData, filePath: sessionPath },
+ };
+ } catch (error) {
+ if (error instanceof SessionError && error.code === 'NO_SESSIONS_FOUND') {
+ if (resumeArg === RESUME_LATEST) {
+ coreEvents.emitFeedback('warning', error.message);
+ return { sessionId: createSessionId() };
+ }
+ }
+ coreEvents.emitFeedback(
+ 'error',
+ `Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ );
+ await runExitCleanup();
+ process.exit(ExitCodes.FATAL_INPUT_ERROR);
+ }
+}
+
+export async function startInteractiveUI(
+ config: Config,
+ settings: LoadedSettings,
+ startupWarnings: StartupWarning[],
+ workspaceRoot: string = process.cwd(),
+ resumedSessionData: ResumedSessionData | undefined,
+ initializationResult: InitializationResult,
+) {
+ // Dynamically import the heavy UI module so React/Ink are only parsed when needed
+ const { startInteractiveUI: doStartUI } = await import('./interactiveCli.js');
+ await doStartUI(
+ config,
+ settings,
+ startupWarnings,
+ workspaceRoot,
+ resumedSessionData,
+ initializationResult,
+ );
+}
+
+export async function main() {
+ let config: Config | undefined;
+ const cliStartupHandle = startupProfiler.start('cli_startup');
+
+ // Listen for admin controls from parent process (IPC) in non-sandbox mode. In
+ // sandbox mode, we re-fetch the admin controls from the server once we enter
+ // the sandbox.
+ // TODO: Cache settings in sandbox mode as well.
+ const adminControlsListner = setupAdminControlsListener();
+ registerCleanup(adminControlsListner.cleanup);
+
+ const cleanupStdio = patchStdio();
+ registerSyncCleanup(() => {
+ // This is needed to ensure we don't lose any buffered output.
+ initializeOutputListenersAndFlush(config);
+ cleanupStdio();
+ });
+
+ setupUnhandledRejectionHandler();
+
+ setupSignalHandlers();
+
+ const slashCommandConflictHandler = new SlashCommandConflictHandler();
+ slashCommandConflictHandler.start();
+ registerCleanup(() => slashCommandConflictHandler.stop());
+
+ const loadSettingsHandle = startupProfiler.start('load_settings');
+ const settings = loadSettings();
+ loadSettingsHandle?.end();
+
+ // If a worktree is requested and enabled, set it up early.
+ // This must be awaited before any other async tasks that depend on CWD (like loadCliConfig)
+ // because setupWorktree calls process.chdir().
+ const requestedWorktree = cliConfig.getRequestedWorktreeName(settings);
+ let worktreeInfo: WorktreeInfo | undefined;
+ if (requestedWorktree !== undefined) {
+ const worktreeHandle = startupProfiler.start('setup_worktree');
+ worktreeInfo = await setupWorktree(requestedWorktree || undefined);
+ worktreeHandle?.end();
+ }
+
+ const cleanupOpsHandle = startupProfiler.start('cleanup_ops');
+ Promise.all([
+ cleanupCheckpoints(),
+ cleanupToolOutputFiles(settings.merged),
+ cleanupBackgroundLogs(),
+ ])
+ .catch((e) => {
+ debugLogger.error('Early cleanup failed:', e);
+ })
+ .finally(() => {
+ cleanupOpsHandle?.end();
+ });
+
+ const parseArgsHandle = startupProfiler.start('parse_arguments');
+ const argvPromise = parseArguments(settings.merged).finally(() => {
+ parseArgsHandle?.end();
+ });
+
+ const rawStartupWarningsPromise = getStartupWarnings();
+
+ // Report settings errors once during startup
+ settings.errors.forEach((error) => {
+ coreEvents.emitFeedback('warning', error.message);
+ });
+
+ const trustedFolders = loadTrustedFolders();
+ trustedFolders.errors.forEach((error: TrustedFoldersError) => {
+ coreEvents.emitFeedback(
+ 'warning',
+ `Error in ${error.path}: ${error.message}`,
+ );
+ });
+
+ const argv = await argvPromise;
+
+ const { sessionId, resumedSessionData } = await resolveSessionId(
+ argv.resume,
+ argv.sessionId,
+ argv.sessionFile,
+ );
+
+ if (
+ (argv.allowedTools && argv.allowedTools.length > 0) ||
+ (settings.merged.tools?.allowed && settings.merged.tools.allowed.length > 0)
+ ) {
+ coreEvents.emitFeedback(
+ 'warning',
+ 'Warning: --allowed-tools cli argument and tools.allowed in settings.json are deprecated and will be removed in 1.0: Migrate to Policy Engine: https://geminicli.com/docs/core/policy-engine/',
+ );
+ }
+
+ if (
+ settings.merged.tools?.exclude &&
+ settings.merged.tools.exclude.length > 0
+ ) {
+ coreEvents.emitFeedback(
+ 'warning',
+ 'Warning: tools.exclude in settings.json is deprecated and will be removed in 1.0. Migrate to Policy Engine: https://geminicli.com/docs/core/policy-engine/',
+ );
+ }
+
+ if (argv.startupMessages) {
+ argv.startupMessages.forEach((msg) => {
+ coreEvents.emitFeedback('info', msg);
+ });
+ }
+
+ // Check for invalid input combinations early to prevent crashes
+ if (argv.promptInteractive && !process.stdin.isTTY) {
+ writeToStderr(
+ 'Error: The --prompt-interactive flag cannot be used when input is piped from stdin.\n',
+ );
+ await runExitCleanup();
+ process.exit(ExitCodes.FATAL_INPUT_ERROR);
+ }
+
+ const isDebugMode = cliConfig.isDebugMode(argv);
+ const consolePatcher = new ConsolePatcher({
+ stderr: argv.isCommand ? false : true,
+ interactive: isHeadlessMode() && !argv.isCommand ? false : true,
+ debugMode: isDebugMode,
+ onNewMessage: (msg) => {
+ coreEvents.emitConsoleLog(msg.type, msg.content);
+ },
+ });
+ consolePatcher.patch();
+
+ dns.setDefaultResultOrder(
+ validateDnsResolutionOrder(settings.merged.advanced.dnsResolutionOrder),
+ );
+
+ // Set a default auth type if one isn't set or is set to a legacy type
+ if (
+ !settings.merged.security.auth.selectedType ||
+ settings.merged.security.auth.selectedType === AuthType.LEGACY_CLOUD_SHELL
+ ) {
+ if (
+ process.env['CLOUD_SHELL'] === 'true' ||
+ process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true'
+ ) {
+ settings.setValue(
+ SettingScope.User,
+ 'security.auth.selectedType',
+ AuthType.COMPUTE_ADC,
+ );
+ }
+ }
+
+ const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
+ projectHooks: settings.workspace.settings.hooks,
+ skipExtensions: true,
+ loadedSettings: settings,
+ });
+
+ adminControlsListner.setConfig(partialConfig);
+
+ // Refresh auth to fetch remote admin settings from CCPA and before entering
+ // the sandbox because the sandbox will interfere with the Oauth2 web
+ // redirect.
+ let initialAuthFailed = false;
+ if (!settings.merged.security.auth.useExternal && !argv.isCommand) {
+ try {
+ if (
+ partialConfig.isInteractive() &&
+ settings.merged.security.auth.selectedType
+ ) {
+ const err = await validateAuthMethod(
+ settings.merged.security.auth.selectedType,
+ );
+ if (err) {
+ throw new Error(err);
+ }
+
+ await partialConfig.refreshAuth(
+ settings.merged.security.auth.selectedType,
+ );
+ } else if (!partialConfig.isInteractive()) {
+ const authType = await validateNonInteractiveAuth(
+ settings.merged.security.auth.selectedType,
+ settings.merged.security.auth.useExternal,
+ partialConfig,
+ settings,
+ );
+ await partialConfig.refreshAuth(authType);
+ }
+ } catch (err) {
+ if (err instanceof ValidationCancelledError) {
+ // User cancelled verification, exit immediately.
+ await runExitCleanup();
+ process.exit(ExitCodes.SUCCESS);
+ }
+
+ // If validation is required, we don't treat it as a fatal failure.
+ // We allow the app to start, and the React-based ValidationDialog
+ // will handle it.
+ if (!(err instanceof ValidationRequiredError)) {
+ debugLogger.error('Error authenticating:', err);
+ initialAuthFailed = true;
+ }
+ }
+ }
+
+ const remoteAdminSettings = partialConfig.getRemoteAdminSettings();
+ // Set remote admin settings if returned from CCPA.
+ if (remoteAdminSettings) {
+ settings.setRemoteAdminSettings(remoteAdminSettings);
+ if (process.send) {
+ process.send({
+ type: 'admin-settings-update',
+ settings: remoteAdminSettings,
+ });
+ }
+ }
+
+ // Run deferred command now that we have admin settings.
+ await runDeferredCommand(settings.merged);
+
+ // hop into sandbox if we are outside and sandboxing is enabled
+ if (!process.env['SANDBOX'] && !argv.isCommand) {
+ const memoryArgs = settings.merged.advanced.autoConfigureMemory
+ ? getNodeMemoryArgs(isDebugMode)
+ : [];
+ const sandboxConfig = await loadSandboxConfig(settings.merged, argv);
+ // We intentionally omit the list of extensions here because extensions
+ // should not impact auth or setting up the sandbox.
+ // TODO(jacobr): refactor loadCliConfig so there is a minimal version
+ // that only initializes enough config to enable refreshAuth or find
+ // another way to decouple refreshAuth from requiring a config.
+
+ if (sandboxConfig) {
+ if (initialAuthFailed) {
+ await runExitCleanup();
+ process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
+ }
+ let stdinData = '';
+ if (!process.stdin.isTTY) {
+ stdinData = await readStdin();
+ }
+
+ // This function is a copy of the one from sandbox.ts
+ // It is moved here to decouple sandbox.ts from the CLI's argument structure.
+ const injectStdinIntoArgs = (
+ args: string[],
+ stdinData?: string,
+ ): string[] => {
+ const finalArgs = [...args];
+ if (stdinData) {
+ const promptIndex = finalArgs.findIndex(
+ (arg) => arg === '--prompt' || arg === '-p',
+ );
+ if (promptIndex > -1 && finalArgs.length > promptIndex + 1) {
+ // If there's a prompt argument, prepend stdin to it
+ finalArgs[promptIndex + 1] =
+ `${stdinData}\n\n${finalArgs[promptIndex + 1]}`;
+ } else {
+ // If there's no prompt argument, add stdin as the prompt
+ finalArgs.push('--prompt', stdinData);
+ }
+ }
+ return finalArgs;
+ };
+
+ const sandboxArgs = injectStdinIntoArgs(process.argv, stdinData);
+
+ await relaunchOnExitCode(() =>
+ start_sandbox(sandboxConfig, memoryArgs, partialConfig, sandboxArgs),
+ );
+ await runExitCleanup();
+ process.exit(ExitCodes.SUCCESS);
+ }
+ }
+
+ // We are now past the logic handling potentially launching a child process
+ // to run Gemini CLI. It is now safe to perform expensive initialization that
+ // may have side effects.
+ {
+ const loadConfigHandle = startupProfiler.start('load_cli_config');
+ config = await loadCliConfig(settings.merged, sessionId, argv, {
+ projectHooks: settings.workspace.settings.hooks,
+ worktreeSettings: worktreeInfo,
+ loadedSettings: settings,
+ });
+ loadConfigHandle?.end();
+
+ // Initialize storage immediately after loading config to ensure that
+ // storage-related operations (like listing or resuming sessions) have
+ // access to the project identifier.
+ await config.storage.initialize();
+
+ adminControlsListner.setConfig(config);
+
+ if (config.isInteractive() && settings.merged.general.devtools) {
+ const { setupInitialActivityLogger } = await import(
+ './utils/devtoolsService.js'
+ );
+ setupInitialActivityLogger(config);
+ }
+
+ // Register config for telemetry shutdown
+ // This ensures telemetry (including SessionEnd hooks) is properly flushed on exit
+ registerTelemetryConfig(config);
+
+ const policyEngine = config.getPolicyEngine();
+ const messageBus = config.getMessageBus();
+ createPolicyUpdater(policyEngine, messageBus, config.storage);
+
+ // Register SessionEnd hook to fire on graceful exit
+ // This runs before telemetry shutdown in runExitCleanup()
+ registerCleanup(async () => {
+ await config?.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
+ });
+
+ // Register ConsolePatcher cleanup last to ensure logs from shutdown hooks
+ // are correctly redirected to stderr (especially for non-interactive JSON output).
+ if (!config.getAcpMode()) {
+ registerCleanup(consolePatcher.cleanup);
+ }
+
+ // Launch cleanup expired sessions as a background task
+ cleanupExpiredSessions(config, settings.merged).catch((e) => {
+ debugLogger.error('Failed to cleanup expired sessions:', e);
+ });
+
+ if (config.getListExtensions()) {
+ debugLogger.log('Installed extensions:');
+ for (const extension of config.getExtensions()) {
+ debugLogger.log(`- ${extension.name}`);
+ }
+ await runExitCleanup();
+ process.exit(ExitCodes.SUCCESS);
+ }
+
+ // Handle --list-sessions flag
+ if (config.getListSessions()) {
+ // Attempt auth for summary generation (gracefully skips if not configured)
+ const authType = settings.merged.security.auth.selectedType;
+ if (authType) {
+ try {
+ await config.refreshAuth(authType);
+ } catch (e) {
+ // Auth failed - continue without summary generation capability
+ debugLogger.debug(
+ 'Auth failed for --list-sessions, summaries may not be generated:',
+ e,
+ );
+ }
+ }
+
+ await listSessions(config);
+ await runExitCleanup();
+ process.exit(ExitCodes.SUCCESS);
+ }
+
+ // Handle --delete-session flag
+ const sessionToDelete = config.getDeleteSession();
+ if (sessionToDelete) {
+ await deleteSession(config, sessionToDelete);
+ await runExitCleanup();
+ process.exit(ExitCodes.SUCCESS);
+ }
+
+ const wasRaw = process.stdin.isRaw;
+ if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {
+ // Set this as early as possible to avoid spurious characters from
+ // input showing up in the output.
+ process.stdin.setRawMode(true);
+
+ // This cleanup isn't strictly needed but may help in certain situations.
+ registerSyncCleanup(() => {
+ process.stdin.setRawMode(wasRaw);
+ });
+ }
+
+ const terminalHandle = startupProfiler.start('setup_terminal');
+ await setupTerminalAndTheme(config, settings);
+ terminalHandle?.end();
+
+ const initAppHandle = startupProfiler.start('initialize_app');
+ const initializationResult = await initializeApp(config, settings);
+ initAppHandle?.end();
+
+ import('./services/liteRtServerManager.js')
+ .then(({ LiteRtServerManager }) => {
+ const mergedGemma = settings.merged.experimental?.gemmaModelRouter;
+ if (!mergedGemma) return;
+ // Security: binaryPath and autoStartServer must come from user-scoped
+ // settings only to prevent workspace configs from triggering arbitrary
+ // binary execution.
+ const userGemma = settings.forScope(SettingScope.User).settings
+ .experimental?.gemmaModelRouter;
+ return LiteRtServerManager.ensureRunning({
+ ...mergedGemma,
+ binaryPath: userGemma?.binaryPath,
+ autoStartServer: userGemma?.autoStartServer,
+ });
+ })
+ .catch((e) => debugLogger.warn('LiteRT auto-start import failed:', e));
+
+ if (
+ settings.merged.security.auth.selectedType ===
+ AuthType.LOGIN_WITH_GOOGLE &&
+ config.isBrowserLaunchSuppressed()
+ ) {
+ // Do oauth before app renders to make copying the link possible.
+ await getOauthClient(settings.merged.security.auth.selectedType, config);
+ }
+
+ if (config.getAcpMode()) {
+ return runAcpClient(config, settings, argv);
+ }
+
+ let input = config.getQuestion();
+ const useAlternateBuffer = shouldEnterAlternateScreen(
+ config.getUseAlternateBuffer(),
+ config.getScreenReader(),
+ );
+ const rawStartupWarnings = await rawStartupWarningsPromise;
+ const startupWarnings: StartupWarning[] = [
+ ...rawStartupWarnings.map((message) => ({
+ id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`,
+ message,
+ priority: WarningPriority.High,
+ })),
+ ...(await getUserStartupWarnings(settings.merged, undefined, {
+ isAlternateBuffer: useAlternateBuffer,
+ })),
+ ];
+
+ cliStartupHandle?.end();
+
+ if (!config.isInteractive()) {
+ for (const warning of startupWarnings) {
+ writeToStderr(warning.message + '\n');
+ }
+ }
+
+ // Render UI, passing necessary config values. Check that there is no command line question.
+ if (config.isInteractive()) {
+ // Earlier initialization phases (like TerminalCapabilityManager resolving
+ // or authWithWeb) may have added and removed 'data' listeners on process.stdin.
+ // When the listener count drops to 0, Node.js implicitly pauses the stream buffer.
+ // React Ink's useInput hooks will silently fail to receive keystrokes if the stream remains paused.
+ if (process.stdin.isTTY) {
+ process.stdin.resume();
+ }
+
+ await startInteractiveUI(
+ config,
+ settings,
+ startupWarnings,
+ process.cwd(),
+ resumedSessionData,
+ initializationResult,
+ );
+ return;
+ }
+
+ await config.initialize();
+ startupProfiler.flush(config);
+
+ // If not a TTY, read from stdin
+ // This is for cases where the user pipes input directly into the command
+ let stdinData: string | undefined = undefined;
+ if (!process.stdin.isTTY) {
+ stdinData = await readStdin();
+ if (stdinData) {
+ input = input ? `${stdinData}\n\n${input}` : stdinData;
+ }
+ }
+
+ // Fire SessionStart hook through MessageBus (only if hooks are enabled)
+ // Must be called AFTER config.initialize() to ensure HookRegistry is loaded
+ const sessionStartSource = resumedSessionData
+ ? SessionStartSource.Resume
+ : SessionStartSource.Startup;
+
+ const hookSystem = config?.getHookSystem();
+ if (hookSystem) {
+ const result = await hookSystem.fireSessionStartEvent(sessionStartSource);
+
+ if (result) {
+ if (result.systemMessage) {
+ writeToStderr(result.systemMessage + '\n');
+ }
+ const additionalContext = result.getAdditionalContext();
+ if (additionalContext) {
+ // Prepend context to input (System Context -> Stdin -> Question)
+ const wrappedContext = `${additionalContext}`;
+ input = input ? `${wrappedContext}\n\n${input}` : wrappedContext;
+ }
+ }
+ }
+
+ if (!input) {
+ debugLogger.error(
+ `No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
+ );
+ await runExitCleanup();
+ process.exit(ExitCodes.FATAL_INPUT_ERROR);
+ }
+
+ const prompt_id = sessionId;
+ logUserPrompt(
+ config,
+ new UserPromptEvent(
+ input.length,
+ prompt_id,
+ config.getContentGeneratorConfig()?.authType,
+ input,
+ ),
+ );
+
+ const authType = await validateNonInteractiveAuth(
+ settings.merged.security.auth.selectedType,
+ settings.merged.security.auth.useExternal,
+ config,
+ settings,
+ );
+ await config.refreshAuth(authType);
+
+ if (config.getDebugMode()) {
+ debugLogger.log('Session ID: %s', sessionId);
+ }
+
+ initializeOutputListenersAndFlush(config);
+
+ await runNonInteractive({
+ config,
+ settings,
+ input,
+ prompt_id,
+ resumedSessionData,
+ });
+ // Call cleanup before process.exit, which causes cleanup to not run
+ await runExitCleanup();
+ process.exit(ExitCodes.SUCCESS);
+ }
+}
+
+export function initializeOutputListenersAndFlush(config?: Config) {
+ // If there are no listeners for output, make sure we flush so output is not
+ // lost.
+ if (coreEvents.listenerCount(CoreEvent.Output) === 0) {
+ // In non-interactive mode, ensure we drain any buffered output or logs to stderr
+ coreEvents.on(CoreEvent.Output, (payload: OutputPayload) => {
+ if (payload.isStderr) {
+ writeToStderr(payload.chunk, payload.encoding);
+ } else {
+ writeToStdout(payload.chunk, payload.encoding);
+ }
+ });
+ }
+
+ if (coreEvents.listenerCount(CoreEvent.ConsoleLog) === 0) {
+ coreEvents.on(CoreEvent.ConsoleLog, (payload: ConsoleLogPayload) => {
+ if (payload.type === 'error' || payload.type === 'warn') {
+ writeToStderr(payload.content + '\n');
+ } else {
+ writeToStderr(payload.content + '\n');
+ }
+ });
+ }
+
+ if (coreEvents.listenerCount(CoreEvent.UserFeedback) === 0) {
+ coreEvents.on(CoreEvent.UserFeedback, (payload: UserFeedbackPayload) => {
+ writeToStderr(payload.message + '\n');
+ });
+ }
+
+ const outputFormat = config?.getOutputFormat();
+ const forceToStderr = outputFormat === 'json';
+
+ coreEvents.drainBacklogs(
+ (event: K, args: CoreEvents[K]) => {
+ if (forceToStderr && event === (CoreEvent.Output as string)) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const payload = args[0] as OutputPayload;
+ if (!payload.isStderr) {
+ return {
+ event,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ args: [{ ...payload, isStderr: true }] as unknown as CoreEvents[K],
+ };
+ }
+ }
+ return { event, args };
+ },
+ );
+}
+
+function setupAdminControlsListener() {
+ let pendingSettings: AdminControlsSettings | undefined;
+ let config: Config | undefined;
+
+ const messageHandler = (msg: unknown) => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const message = msg as {
+ type?: string;
+ settings?: AdminControlsSettings;
+ };
+ if (message?.type === 'admin-settings' && message.settings) {
+ if (config) {
+ config.setRemoteAdminSettings(message.settings);
+ } else {
+ pendingSettings = message.settings;
+ }
+ }
+ };
+
+ process.on('message', messageHandler);
+
+ return {
+ setConfig: (newConfig: Config) => {
+ config = newConfig;
+ if (pendingSettings) {
+ config.setRemoteAdminSettings(pendingSettings);
+ }
+ },
+ cleanup: () => {
+ process.off('message', messageHandler);
+ },
+ };
+}
diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..75e9cf3959d0050fd5cd6546578eb57165936a23
--- /dev/null
+++ b/packages/cli/src/gemini_cleanup.test.tsx
@@ -0,0 +1,465 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { main } from './gemini.js';
+import {
+ debugLogger,
+ SessionEndReason,
+ type Config,
+ type HookSystem,
+} from '@google/gemini-cli-core';
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ writeToStdout: vi.fn(),
+ patchStdio: vi.fn(() => () => {}),
+ createWorkingStdio: vi.fn(() => ({
+ stdout: {
+ write: vi.fn(),
+ columns: 80,
+ rows: 24,
+ on: vi.fn(),
+ removeListener: vi.fn(),
+ },
+ stderr: { write: vi.fn() },
+ })),
+ enableMouseEvents: vi.fn(),
+ disableMouseEvents: vi.fn(),
+ enterAlternateScreen: vi.fn(),
+ disableLineWrapping: vi.fn(),
+ ProjectRegistry: vi.fn().mockImplementation(() => ({
+ initialize: vi.fn(),
+ getShortId: vi.fn().mockReturnValue('project-slug'),
+ })),
+ };
+});
+
+vi.mock('ink', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ render: vi.fn(() => ({
+ unmount: vi.fn(),
+ rerender: vi.fn(),
+ cleanup: vi.fn(),
+ waitUntilExit: vi.fn(),
+ })),
+ };
+});
+
+vi.mock('./config/settings.js', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ loadSettings: vi.fn().mockReturnValue({
+ merged: { advanced: {}, security: { auth: {} }, ui: {} },
+ workspace: { settings: {} },
+ setValue: vi.fn(),
+ forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
+ errors: [],
+ }),
+ };
+});
+
+vi.mock('./ui/utils/ConsolePatcher.js', () => ({
+ ConsolePatcher: vi.fn().mockImplementation(() => ({
+ patch: vi.fn(),
+ cleanup: vi.fn(),
+ })),
+}));
+
+vi.mock('./config/config.js', () => ({
+ loadCliConfig: vi.fn().mockResolvedValue({
+ getSandbox: vi.fn(() => false),
+ getQuestion: vi.fn(() => ''),
+ isInteractive: () => false,
+ getSessionId: vi.fn().mockReturnValue('test-session-id'),
+ storage: { initialize: vi.fn().mockResolvedValue(undefined) },
+ } as unknown as Config),
+ parseArguments: vi.fn().mockResolvedValue({}),
+ isDebugMode: vi.fn(() => false),
+ getRequestedWorktreeName: vi.fn(() => undefined),
+ getWorktreeArg: vi.fn(() => undefined),
+}));
+
+vi.mock('read-package-up', () => ({
+ readPackageUp: vi.fn().mockResolvedValue({
+ packageJson: { name: 'test-pkg', version: 'test-version' },
+ path: '/fake/path/package.json',
+ }),
+}));
+
+vi.mock('update-notifier', () => ({
+ default: vi.fn(() => ({ notify: vi.fn() })),
+}));
+
+vi.mock('./utils/events.js', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, appEvents: { emit: vi.fn() } };
+});
+
+vi.mock('./utils/sandbox.js', () => ({
+ sandbox_command: vi.fn(() => ''),
+ start_sandbox: vi.fn(() => Promise.resolve()),
+}));
+
+vi.mock('./utils/relaunch.js', () => ({
+ relaunchAppInChildProcess: vi.fn(),
+ relaunchOnExitCode: vi.fn(),
+}));
+
+vi.mock('./config/sandboxConfig.js', () => ({
+ loadSandboxConfig: vi.fn(),
+}));
+
+vi.mock('./ui/utils/mouse.js', () => ({
+ enableMouseEvents: vi.fn(),
+ disableMouseEvents: vi.fn(),
+ parseMouseEvent: vi.fn(),
+ isIncompleteMouseSequence: vi.fn(),
+}));
+
+vi.mock('./validateNonInterActiveAuth.js', () => ({
+ validateNonInteractiveAuth: vi.fn().mockResolvedValue({}),
+}));
+
+vi.mock('./core/initializer.js', () => ({
+ initializeApp: vi.fn().mockResolvedValue({
+ authError: null,
+ themeError: null,
+ shouldOpenAuthDialog: false,
+ geminiMdFileCount: 0,
+ }),
+}));
+
+vi.mock('./nonInteractiveCli.js', () => ({
+ runNonInteractive: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock('./utils/cleanup.js', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ cleanupCheckpoints: vi.fn().mockResolvedValue(undefined),
+ registerCleanup: vi.fn(),
+ removeCleanup: vi.fn(),
+ registerSyncCleanup: vi.fn(),
+ removeSyncCleanup: vi.fn(),
+ registerTelemetryConfig: vi.fn(),
+ runExitCleanup: vi.fn().mockResolvedValue(undefined),
+ };
+});
+
+vi.mock('./acp/acpStdioTransport.js', () => ({
+ runAcpClient: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock('./zed-integration/zedIntegration.js', () => ({
+ runZedIntegration: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock('./utils/readStdin.js', () => ({
+ readStdin: vi.fn().mockResolvedValue(''),
+}));
+
+const { cleanupMockState } = vi.hoisted(() => ({
+ cleanupMockState: { shouldThrow: false, called: false },
+}));
+
+// Mock sessionCleanup.js at the top level
+vi.mock('./utils/sessionCleanup.js', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ cleanupExpiredSessions: async () => {
+ cleanupMockState.called = true;
+ if (cleanupMockState.shouldThrow) {
+ throw new Error('Cleanup failed');
+ }
+ },
+ };
+});
+
+describe('gemini.tsx main function cleanup', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
+ });
+
+ afterEach(() => {
+ delete process.env['GEMINI_CLI_NO_RELAUNCH'];
+ vi.restoreAllMocks();
+ });
+
+ it.skip('should log error when cleanupExpiredSessions fails', async () => {
+ const { loadCliConfig, parseArguments } = await import(
+ './config/config.js'
+ );
+ const { loadSettings } = await import('./config/settings.js');
+ cleanupMockState.shouldThrow = true;
+ cleanupMockState.called = false;
+
+ const debugLoggerErrorSpy = vi
+ .spyOn(debugLogger, 'error')
+ .mockImplementation(() => {});
+ vi.mocked(loadSettings).mockReturnValue({
+ merged: { advanced: {}, security: { auth: {} }, ui: {} },
+ workspace: { settings: {} },
+ setValue: vi.fn(),
+ forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
+ errors: [],
+ } as unknown as ReturnType);
+
+ vi.mocked(parseArguments).mockResolvedValue({
+ promptInteractive: false,
+ } as unknown as Awaited>);
+ vi.mocked(loadCliConfig).mockResolvedValue({
+ isInteractive: vi.fn(() => false),
+ getQuestion: vi.fn(() => 'test'),
+ getSandbox: vi.fn(() => false),
+ getDebugMode: vi.fn(() => false),
+ getPolicyEngine: vi.fn(),
+ getSessionId: vi.fn().mockReturnValue('test-session-id'),
+ getMessageBus: () => ({ subscribe: vi.fn() }),
+ getEnableHooks: vi.fn(() => false),
+ getHookSystem: () => undefined,
+ initialize: vi.fn(),
+ storage: { initialize: vi.fn().mockResolvedValue(undefined) },
+ getContentGeneratorConfig: vi.fn(),
+ getMcpServers: () => ({}),
+ getMcpClientManager: vi.fn(),
+ getIdeMode: vi.fn(() => false),
+ getAcpMode: vi.fn(() => true),
+ getScreenReader: vi.fn(() => false),
+ getGeminiMdFileCount: vi.fn(() => 0),
+ getProjectRoot: vi.fn(() => '/'),
+ getListExtensions: vi.fn(() => false),
+ getListSessions: vi.fn(() => false),
+ getDeleteSession: vi.fn(() => undefined),
+ getToolRegistry: vi.fn(),
+ getExtensions: vi.fn(() => []),
+ getModel: vi.fn(() => 'gemini-pro'),
+ getEmbeddingModel: vi.fn(() => 'embedding-001'),
+ getApprovalMode: vi.fn(() => 'default'),
+ getCoreTools: vi.fn(() => []),
+ getTelemetryEnabled: vi.fn(() => false),
+ getTelemetryLogPromptsEnabled: vi.fn(() => false),
+ getFileFilteringRespectGitIgnore: vi.fn(() => true),
+ getOutputFormat: vi.fn(() => 'text'),
+ getUsageStatisticsEnabled: vi.fn(() => false),
+ setTerminalBackground: vi.fn(),
+ refreshAuth: vi.fn(),
+ getRemoteAdminSettings: vi.fn(() => undefined),
+ getUseAlternateBuffer: vi.fn(() => false),
+ } as unknown as Config);
+
+ await main();
+
+ expect(cleanupMockState.called).toBe(true);
+ expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
+ 'Failed to cleanup expired sessions:',
+ expect.objectContaining({ message: 'Cleanup failed' }),
+ );
+ });
+
+ it('should register SessionEnd hook exactly once in non-interactive mode', async () => {
+ const { loadCliConfig, parseArguments } = await import(
+ './config/config.js'
+ );
+ const { registerCleanup } = await import('./utils/cleanup.js');
+
+ const mockHookSystem = {
+ fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
+ fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
+ } as unknown as HookSystem;
+
+ vi.mocked(parseArguments).mockResolvedValue({
+ promptInteractive: false,
+ } as unknown as Awaited>);
+
+ vi.mocked(loadCliConfig).mockResolvedValue(
+ buildMockConfig({
+ getHookSystem: vi.fn(() => mockHookSystem),
+ getSessionId: vi.fn().mockReturnValue('test-session-id'),
+ }),
+ );
+
+ vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
+
+ await main();
+
+ const registeredCallbacks = vi
+ .mocked(registerCleanup)
+ .mock.calls.map(([fn]) => fn);
+ for (const fn of registeredCallbacks) await fn();
+ expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledTimes(1);
+ expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith(
+ SessionEndReason.Exit,
+ );
+ });
+
+ it('should not register ConsolePatcher cleanup in ACP mode', async () => {
+ const { registerCleanup } = await import('./utils/cleanup.js');
+ const { ConsolePatcher } = await import('./ui/utils/ConsolePatcher.js');
+ const { loadCliConfig, parseArguments } = await import(
+ './config/config.js'
+ );
+ const { loadSettings } = await import('./config/settings.js');
+
+ vi.mocked(parseArguments).mockResolvedValue({
+ acp: true,
+ startupMessages: [],
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any);
+
+ vi.mocked(loadSettings).mockReturnValue({
+ merged: {
+ tools: { allowed: [], exclude: [] },
+ advanced: { dnsResolutionOrder: 'ipv4first' },
+ security: { auth: { selectedType: 'google' } },
+ ui: { theme: 'default' },
+ },
+ workspace: { settings: {} },
+ errors: [],
+ subscribe: vi.fn(),
+ getSnapshot: vi.fn(),
+ setValue: vi.fn(),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any);
+
+ vi.mocked(loadCliConfig).mockResolvedValue(
+ buildMockConfig({
+ getAcpMode: () => true,
+ }),
+ );
+
+ let capturedCleanup: () => void;
+ vi.mocked(ConsolePatcher).mockImplementation(() => {
+ const instance = {
+ patch: vi.fn(),
+ cleanup: vi.fn(),
+ };
+ capturedCleanup = instance.cleanup;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return instance as any;
+ });
+
+ await main();
+
+ const registeredFunctions = vi
+ .mocked(registerCleanup)
+ .mock.calls.map((call) => call[0]);
+ expect(registeredFunctions).not.toContain(capturedCleanup!);
+ });
+
+ it('should register ConsolePatcher cleanup in non-ACP mode', async () => {
+ const { registerCleanup } = await import('./utils/cleanup.js');
+ const { ConsolePatcher } = await import('./ui/utils/ConsolePatcher.js');
+ const { loadCliConfig, parseArguments } = await import(
+ './config/config.js'
+ );
+ const { loadSettings } = await import('./config/settings.js');
+
+ vi.mocked(parseArguments).mockResolvedValue({
+ acp: false,
+ query: 'test',
+ startupMessages: [],
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any);
+
+ vi.mocked(loadSettings).mockReturnValue({
+ merged: {
+ tools: { allowed: [], exclude: [] },
+ advanced: { dnsResolutionOrder: 'ipv4first' },
+ security: { auth: { selectedType: 'google' } },
+ ui: { theme: 'default' },
+ },
+ workspace: { settings: {} },
+ errors: [],
+ subscribe: vi.fn(),
+ getSnapshot: vi.fn(),
+ setValue: vi.fn(),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any);
+
+ vi.mocked(loadCliConfig).mockResolvedValue(
+ buildMockConfig({
+ getAcpMode: () => false,
+ getQuestion: () => 'test',
+ }),
+ );
+
+ let capturedCleanup: () => void;
+ vi.mocked(ConsolePatcher).mockImplementation(() => {
+ const instance = {
+ patch: vi.fn(),
+ cleanup: vi.fn(),
+ };
+ capturedCleanup = instance.cleanup;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return instance as any;
+ });
+
+ try {
+ await main();
+ } catch {
+ // Ignore errors from incomplete mocks in full main() execution
+ }
+
+ const registeredFunctions = vi
+ .mocked(registerCleanup)
+ .mock.calls.map((call) => call[0]);
+ expect(registeredFunctions).toContain(capturedCleanup!);
+ });
+
+ function buildMockConfig(overrides: Partial = {}): Config {
+ return {
+ isInteractive: vi.fn(() => false),
+ getQuestion: vi.fn(() => 'test'),
+ getSandbox: vi.fn(() => false),
+ getDebugMode: vi.fn(() => false),
+ getPolicyEngine: vi.fn(),
+ getMessageBus: () => ({ subscribe: vi.fn() }),
+ getEnableHooks: vi.fn(() => true),
+ getHookSystem: vi.fn(() => undefined),
+ getExperimentalGemma: vi.fn(() => false),
+ initialize: vi.fn(),
+ storage: { initialize: vi.fn().mockResolvedValue(undefined) },
+ getContentGeneratorConfig: vi.fn(),
+ getMcpClientManager: vi.fn(),
+ getIdeMode: vi.fn(() => false),
+ getAcpMode: vi.fn(() => false),
+ getScreenReader: vi.fn(() => false),
+ getGeminiMdFileCount: vi.fn(() => 0),
+ getProjectRoot: vi.fn(() => '/'),
+ getListExtensions: vi.fn(() => false),
+ getListSessions: vi.fn(() => false),
+ getDeleteSession: vi.fn(() => undefined),
+ getExtensions: vi.fn(() => []),
+ getModel: vi.fn(() => 'gemini-pro'),
+ getEmbeddingModel: vi.fn(() => 'embedding-001'),
+ getApprovalMode: vi.fn(() => 'default'),
+ getCoreTools: vi.fn(() => []),
+ getTelemetryEnabled: vi.fn(() => false),
+ getTelemetryLogPromptsEnabled: vi.fn(() => false),
+ getFileFilteringRespectGitIgnore: vi.fn(() => true),
+ getOutputFormat: vi.fn(() => 'text'),
+ getUsageStatisticsEnabled: vi.fn(() => false),
+ setTerminalBackground: vi.fn(),
+ refreshAuth: vi.fn(),
+ getRemoteAdminSettings: vi.fn(() => undefined),
+ getUseAlternateBuffer: vi.fn(() => false),
+ getUseTerminalBuffer: vi.fn(() => false),
+ ...overrides,
+ } as unknown as Config;
+ }
+});
diff --git a/packages/cli/src/interactiveCli.tsx b/packages/cli/src/interactiveCli.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..266788745eebe4b885da5fa98f14a941430af672
--- /dev/null
+++ b/packages/cli/src/interactiveCli.tsx
@@ -0,0 +1,275 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { render } from 'ink';
+import { basename } from 'node:path';
+import { AppContainer } from './ui/AppContainer.js';
+import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
+import {
+ registerCleanup,
+ removeCleanup,
+ setupTtyCheck,
+} from './utils/cleanup.js';
+import {
+ type StartupWarning,
+ type Config,
+ type ResumedSessionData,
+ coreEvents,
+ createWorkingStdio,
+ disableMouseEvents,
+ enableMouseEvents,
+ disableLineWrapping,
+ enableLineWrapping,
+ shouldEnterAlternateScreen,
+ recordSlowRender,
+ writeToStdout,
+ getVersion,
+ debugLogger,
+} from '@google/gemini-cli-core';
+import type { InitializationResult } from './core/initializer.js';
+import type { LoadedSettings } from './config/settings.js';
+import { checkForUpdates } from './ui/utils/updateCheck.js';
+import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
+import { SettingsContext } from './ui/contexts/SettingsContext.js';
+import { MouseProvider } from './ui/contexts/MouseContext.js';
+import { StreamingState } from './ui/types.js';
+import { computeTerminalTitle } from './utils/windowTitle.js';
+
+import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
+import { VimModeProvider } from './ui/contexts/VimModeContext.js';
+import { KeyMatchersProvider } from './ui/hooks/useKeyMatchers.js';
+import { loadKeyMatchers } from './ui/key/keyMatchers.js';
+import { KeypressProvider } from './ui/contexts/KeypressContext.js';
+import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
+import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
+import { TerminalProvider } from './ui/contexts/TerminalContext.js';
+import { OverflowProvider } from './ui/contexts/OverflowContext.js';
+import { profiler } from './ui/components/DebugProfiler.js';
+import { initializeConsoleStore } from './ui/hooks/useConsoleMessages.js';
+
+const SLOW_RENDER_MS = 200;
+
+export async function startInteractiveUI(
+ config: Config,
+ settings: LoadedSettings,
+ startupWarnings: StartupWarning[],
+ workspaceRoot: string = process.cwd(),
+ resumedSessionData: ResumedSessionData | undefined,
+ initializationResult: InitializationResult,
+) {
+ initializeConsoleStore();
+ // Never enter Ink alternate buffer mode when screen reader mode is enabled
+ // as there is no benefit of alternate buffer mode when using a screen reader
+ // and the Ink alternate buffer mode requires line wrapping harmful to
+ // screen readers.
+ const useAlternateBuffer = shouldEnterAlternateScreen(
+ config.getUseAlternateBuffer(),
+ config.getScreenReader(),
+ );
+ const mouseEventsEnabled = useAlternateBuffer;
+ if (mouseEventsEnabled) {
+ enableMouseEvents();
+ registerCleanup(() => {
+ disableMouseEvents();
+ });
+ }
+
+ const { matchers, errors } = await loadKeyMatchers();
+ errors.forEach((error) => {
+ coreEvents.emitFeedback('warning', error);
+ });
+
+ const version = await getVersion();
+ setWindowTitle(basename(workspaceRoot), settings);
+
+ const consolePatcher = new ConsolePatcher({
+ onNewMessage: (msg) => {
+ coreEvents.emitConsoleLog(msg.type, msg.content);
+ },
+ debugMode: config.getDebugMode(),
+ });
+ consolePatcher.patch();
+
+ const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
+
+ const isShpool = !!process.env['SHPOOL_SESSION_NAME'];
+
+ // Create wrapper component to use hooks inside render
+ const AppWrapper = () => {
+ useKittyKeyboardProtocol();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ };
+
+ if (isShpool) {
+ // Wait a moment for shpool to stabilize terminal size and state.
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ const instance = render(
+ process.env['DEBUG'] ? (
+
+
+
+ ) : (
+
+ ),
+ {
+ stdout: inkStdout,
+ stderr: inkStderr,
+ stdin: process.stdin,
+ exitOnCtrlC: false,
+ isScreenReaderEnabled: config.getScreenReader(),
+ onRender: ({ renderTime }: { renderTime: number }) => {
+ if (renderTime > SLOW_RENDER_MS) {
+ recordSlowRender(config, Math.round(renderTime));
+ }
+ profiler.reportFrameRendered();
+ },
+ standardReactLayoutTiming:
+ useAlternateBuffer || config.getUseTerminalBuffer(),
+ patchConsole: false,
+ alternateBuffer: useAlternateBuffer,
+ terminalBuffer: config.getUseTerminalBuffer(),
+ renderProcess:
+ config.getUseRenderProcess() && config.getUseTerminalBuffer(),
+ incrementalRendering:
+ settings.merged.ui.incrementalRendering !== false &&
+ useAlternateBuffer &&
+ !isShpool,
+ debugRainbow: settings.merged.ui.debugRainbow === true,
+ },
+ );
+
+ let cleanupLineWrapping: (() => void) | undefined;
+ if (useAlternateBuffer) {
+ disableLineWrapping();
+ cleanupLineWrapping = () => enableLineWrapping();
+ registerCleanup(cleanupLineWrapping);
+ }
+
+ checkForUpdates(settings)
+ .then((info) => {
+ handleAutoUpdate(
+ info,
+ settings,
+ config.getProjectRoot(),
+ config.getSandboxEnabled(),
+ );
+ })
+ .catch((err) => {
+ // Silently ignore update check errors.
+ if (config.getDebugMode()) {
+ debugLogger.warn('Update check failed:', err);
+ }
+ });
+
+ const cleanupUnmount = () => instance.unmount();
+ const cleanupNonResumableCurrentSession = async () => {
+ try {
+ await config
+ .getGeminiClient()
+ ?.getChatRecordingService()
+ ?.deleteCurrentSessionIfNotResumableAsync();
+ } catch (e: unknown) {
+ debugLogger.error('Error cleaning up non-resumable session:', e);
+ }
+ };
+ registerCleanup(cleanupNonResumableCurrentSession);
+ registerCleanup(cleanupUnmount);
+
+ const cleanupTtyCheck = setupTtyCheck();
+ registerCleanup(cleanupTtyCheck);
+
+ const cleanupConsolePatcher = () => consolePatcher.cleanup();
+ registerCleanup(cleanupConsolePatcher);
+
+ try {
+ await instance.waitUntilExit();
+ } finally {
+ try {
+ removeCleanup(cleanupConsolePatcher);
+ cleanupConsolePatcher();
+ } catch (e: unknown) {
+ debugLogger.error('Error cleaning up console patcher:', e);
+ }
+
+ try {
+ removeCleanup(cleanupNonResumableCurrentSession);
+ await cleanupNonResumableCurrentSession();
+ } catch (e: unknown) {
+ debugLogger.error('Error removing non-resumable session cleanup:', e);
+ }
+
+ try {
+ removeCleanup(cleanupUnmount);
+ instance.unmount();
+ } catch (e: unknown) {
+ debugLogger.error('Error unmounting Ink instance:', e);
+ }
+
+ try {
+ removeCleanup(cleanupTtyCheck);
+ cleanupTtyCheck();
+ } catch (e: unknown) {
+ debugLogger.error('Error in TTY cleanup:', e);
+ }
+
+ if (cleanupLineWrapping) {
+ try {
+ removeCleanup(cleanupLineWrapping);
+ cleanupLineWrapping();
+ } catch (e: unknown) {
+ debugLogger.error('Error restoring line wrapping:', e);
+ }
+ }
+ }
+}
+
+function setWindowTitle(title: string, settings: LoadedSettings) {
+ if (!settings.merged.ui.hideWindowTitle) {
+ // Initial state before React loop starts
+ const windowTitle = computeTerminalTitle({
+ streamingState: StreamingState.Idle,
+ isConfirming: false,
+ isSilentWorking: false,
+ folderName: title,
+ showThoughts: !!settings.merged.ui.showStatusInTitle,
+ useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
+ });
+ writeToStdout(`\x1b]0;${windowTitle}\x07`);
+
+ process.on('exit', () => {
+ writeToStdout(`\x1b]0;\x07`);
+ });
+ }
+}
diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8a036ec522b7cffa0256760750f666c0ecc5bde5
--- /dev/null
+++ b/packages/cli/src/nonInteractiveCli.test.ts
@@ -0,0 +1,2621 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {
+ Config,
+ ToolRegistry,
+ ServerGeminiStreamEvent,
+ SessionMetrics,
+ AnyDeclarativeTool,
+ AnyToolInvocation,
+ UserFeedbackPayload,
+} from '@google/gemini-cli-core';
+import {
+ ToolErrorType,
+ GeminiEventType,
+ OutputFormat,
+ uiTelemetryService,
+ FatalInputError,
+ CoreEvent,
+ CoreToolCallStatus,
+ JsonStreamEventType,
+ TRUE_EMPTY_RESPONSE_MESSAGE,
+} from '@google/gemini-cli-core';
+import type { Part } from '@google/genai';
+import { runNonInteractive } from './nonInteractiveCli.js';
+import {
+ describe,
+ it,
+ expect,
+ beforeEach,
+ afterEach,
+ vi,
+ type Mock,
+ type MockInstance,
+} from 'vitest';
+import type { LoadedSettings } from './config/settings.js';
+
+// Mock core modules
+vi.mock('./ui/hooks/atCommandProcessor.js');
+
+const mockSetupInitialActivityLogger = vi.hoisted(() => vi.fn());
+vi.mock('./utils/devtoolsService.js', () => ({
+ setupInitialActivityLogger: mockSetupInitialActivityLogger,
+}));
+
+const mockCoreEvents = vi.hoisted(() => ({
+ on: vi.fn(),
+ off: vi.fn(),
+ emit: vi.fn(),
+ emitConsoleLog: vi.fn(),
+ emitFeedback: vi.fn(),
+ drainBacklogs: vi.fn(),
+}));
+
+const mockSchedulerSchedule = vi.hoisted(() => vi.fn());
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const original =
+ await importOriginal();
+
+ class MockChatRecordingService {
+ initialize = vi.fn();
+ recordMessage = vi.fn();
+ recordMessageTokens = vi.fn();
+ recordToolCalls = vi.fn();
+ }
+
+ return {
+ ...original,
+ Scheduler: class {
+ schedule = mockSchedulerSchedule;
+ cancelAll = vi.fn();
+ dispose = vi.fn();
+ },
+ isTelemetrySdkInitialized: vi.fn().mockReturnValue(true),
+ ChatRecordingService: MockChatRecordingService,
+ uiTelemetryService: {
+ getMetrics: vi.fn(),
+ recordSemanticValidationError: vi.fn(),
+ },
+ coreEvents: mockCoreEvents,
+ createWorkingStdio: vi.fn(() => ({
+ stdout: process.stdout,
+ stderr: process.stderr,
+ })),
+ };
+});
+
+const mockGetCommands = vi.hoisted(() => vi.fn());
+const mockCommandServiceCreate = vi.hoisted(() => vi.fn());
+vi.mock('./services/CommandService.js', () => ({
+ CommandService: {
+ create: mockCommandServiceCreate,
+ },
+}));
+
+vi.mock('./services/FileCommandLoader.js');
+vi.mock('./services/McpPromptLoader.js');
+vi.mock('./services/BuiltinCommandLoader.js');
+
+describe('runNonInteractive', () => {
+ let mockConfig: Config;
+ let mockSettings: LoadedSettings;
+ let mockToolRegistry: ToolRegistry;
+ let consoleErrorSpy: MockInstance;
+ let processStdoutSpy: MockInstance;
+ let processStderrSpy: MockInstance;
+ let mockGeminiClient: {
+ sendMessageStream: Mock;
+ resumeChat: Mock;
+ getChatRecordingService: Mock;
+ getCurrentSequenceModel: Mock;
+ };
+ const MOCK_SESSION_METRICS: SessionMetrics = {
+ models: {},
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: {
+ accept: 0,
+ reject: 0,
+ modify: 0,
+ auto_accept: 0,
+ },
+ byName: {},
+ },
+ files: {
+ totalLinesAdded: 0,
+ totalLinesRemoved: 0,
+ },
+ };
+
+ beforeEach(async () => {
+ mockSchedulerSchedule.mockReset();
+
+ mockCommandServiceCreate.mockResolvedValue({
+ getCommands: mockGetCommands,
+ });
+
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ processStdoutSpy = vi
+ .spyOn(process.stdout, 'write')
+ .mockImplementation(() => true);
+ vi.spyOn(process.stdout, 'on').mockImplementation(() => process.stdout);
+ processStderrSpy = vi
+ .spyOn(process.stderr, 'write')
+ .mockImplementation(() => true);
+ vi.spyOn(process, 'exit').mockImplementation((code) => {
+ throw new Error(`process.exit(${code}) called`);
+ });
+
+ mockToolRegistry = {
+ getTool: vi.fn(),
+ getFunctionDeclarations: vi.fn().mockReturnValue([]),
+ } as unknown as ToolRegistry;
+
+ mockGeminiClient = {
+ sendMessageStream: vi.fn(),
+ resumeChat: vi.fn().mockResolvedValue(undefined),
+ getChatRecordingService: vi.fn(() => ({
+ initialize: vi.fn(),
+ recordMessage: vi.fn(),
+ recordMessageTokens: vi.fn(),
+ recordToolCalls: vi.fn(),
+ })),
+ getCurrentSequenceModel: vi.fn().mockReturnValue('gemini-2.5-flash'),
+ };
+
+ mockConfig = {
+ initialize: vi.fn().mockReturnValue(Promise.resolve(undefined)),
+ getMessageBus: vi.fn().mockReturnValue({
+ subscribe: vi.fn(),
+ unsubscribe: vi.fn(),
+ publish: vi.fn(),
+ }),
+ getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
+ getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
+ getMaxSessionTurns: vi.fn().mockReturnValue(10),
+ getSessionId: vi.fn().mockReturnValue('test-session-id'),
+ getProjectRoot: vi.fn().mockReturnValue('/test/project'),
+ storage: {
+ getProjectTempDir: vi.fn().mockReturnValue('/test/project/.gemini/tmp'),
+ },
+ getIdeMode: vi.fn().mockReturnValue(false),
+
+ getContentGeneratorConfig: vi.fn().mockReturnValue({}),
+ getDebugMode: vi.fn().mockReturnValue(false),
+ getOutputFormat: vi.fn().mockReturnValue('text'),
+ getModel: vi.fn().mockReturnValue('test-model'),
+ getFolderTrust: vi.fn().mockReturnValue(false),
+ isTrustedFolder: vi.fn().mockReturnValue(false),
+ getRawOutput: vi.fn().mockReturnValue(false),
+ getAcceptRawOutputRisk: vi.fn().mockReturnValue(false),
+ getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false),
+ getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
+ } as unknown as Config;
+
+ mockSettings = {
+ system: { path: '', settings: {} },
+ systemDefaults: { path: '', settings: {} },
+ user: { path: '', settings: {} },
+ workspace: { path: '', settings: {} },
+ errors: [],
+ setValue: vi.fn(),
+ merged: {
+ security: {
+ auth: {
+ enforcedType: undefined,
+ },
+ },
+ },
+ isTrusted: true,
+ migratedInMemoryScopes: new Set(),
+ forScope: vi.fn(),
+ computeMergedSettings: vi.fn(),
+ } as unknown as LoadedSettings;
+
+ const { handleAtCommand } = await import(
+ './ui/hooks/atCommandProcessor.js'
+ );
+ vi.mocked(handleAtCommand).mockImplementation(async ({ query }) => ({
+ processedQuery: [{ text: query }],
+ }));
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ async function* createStreamFromEvents(
+ events: ServerGeminiStreamEvent[],
+ ): AsyncGenerator {
+ for (const event of events) {
+ yield event;
+ }
+ }
+
+ const getWrittenOutput = () =>
+ processStdoutSpy.mock.calls.map((c) => c[0]).join('');
+
+ it('should process input and write text output', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Hello' },
+ { type: GeminiEventType.Content, value: ' World' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-1',
+ });
+
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
+ [{ text: 'Test input' }],
+ expect.any(AbortSignal),
+ 'prompt-id-1',
+ undefined,
+ 'Test input',
+ );
+ expect(getWrittenOutput()).toBe('Hello World\n');
+ // Note: Telemetry shutdown is now handled in runExitCleanup() in cleanup.ts
+ // so we no longer expect shutdownTelemetry to be called directly here
+ });
+
+ it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is set', async () => {
+ vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', '/tmp/test.jsonl');
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test',
+ prompt_id: 'prompt-id-activity-logger',
+ });
+
+ expect(mockSetupInitialActivityLogger).toHaveBeenCalledWith(mockConfig);
+ vi.unstubAllEnvs();
+ });
+
+ it('should not register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is not set', async () => {
+ vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', '');
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test',
+ prompt_id: 'prompt-id-activity-logger-off',
+ });
+
+ expect(mockSetupInitialActivityLogger).not.toHaveBeenCalled();
+ vi.unstubAllEnvs();
+ });
+
+ it('should handle a single tool call and respond', async () => {
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'tool-1',
+ name: 'testTool',
+ args: { arg1: 'value1' },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-2',
+ },
+ };
+ const toolResponse: Part[] = [{ text: 'Tool response' }];
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Success,
+ request: {
+ callId: 'tool-1',
+ name: 'testTool',
+ args: { arg1: 'value1' },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-2',
+ },
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ responseParts: toolResponse,
+ callId: 'tool-1',
+ error: undefined,
+ errorType: undefined,
+ contentLength: undefined,
+ },
+ },
+ ]);
+
+ const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
+ const secondCallEvents: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Final answer' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
+ .mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Use a tool',
+ prompt_id: 'prompt-id-2',
+ });
+
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
+ expect(mockSchedulerSchedule).toHaveBeenCalledWith(
+ [expect.objectContaining({ name: 'testTool' })],
+ expect.any(AbortSignal),
+ );
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
+ 2,
+ [{ text: 'Tool response' }],
+ expect.any(AbortSignal),
+ 'prompt-id-2',
+ undefined,
+ undefined,
+ );
+ expect(getWrittenOutput()).toBe('Final answer\n');
+ });
+
+ it('should write a single newline between sequential text outputs from the model', async () => {
+ // This test simulates a multi-turn conversation to ensure that a single newline
+ // is printed between each block of text output from the model.
+
+ // 1. Define the tool requests that the model will ask the CLI to run.
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'mock-tool',
+ name: 'mockTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-multi',
+ },
+ };
+
+ // 2. Mock the execution of the tools. We just need them to succeed.
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Success,
+ request: toolCallEvent.value, // This is generic enough for both calls
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ responseParts: [],
+ callId: 'mock-tool',
+ },
+ },
+ ]);
+
+ // 3. Define the sequence of events streamed from the mock model.
+ // Turn 1: Model outputs text, then requests a tool call.
+ const modelTurn1: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Use mock tool' },
+ toolCallEvent,
+ ];
+ // Turn 2: Model outputs more text, then requests another tool call.
+ const modelTurn2: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Use mock tool again' },
+ toolCallEvent,
+ ];
+ // Turn 3: Model outputs a final answer.
+ const modelTurn3: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Finished.' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(createStreamFromEvents(modelTurn1))
+ .mockReturnValueOnce(createStreamFromEvents(modelTurn2))
+ .mockReturnValueOnce(createStreamFromEvents(modelTurn3));
+
+ // 4. Run the command.
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Use mock tool multiple times',
+ prompt_id: 'prompt-id-multi',
+ });
+
+ // 5. Verify the output.
+ // The rendered output should contain the text from each turn, separated by a
+ // single newline, with a final newline at the end.
+ expect(getWrittenOutput()).toMatchSnapshot();
+
+ // Also verify the tools were called as expected.
+ expect(mockSchedulerSchedule).toHaveBeenCalledTimes(2);
+ });
+
+ it('should handle error during tool execution and should send error back to the model', async () => {
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'tool-1',
+ name: 'errorTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-3',
+ },
+ };
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Error,
+ request: {
+ callId: 'tool-1',
+ name: 'errorTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-3',
+ },
+ tool: {} as AnyDeclarativeTool,
+ response: {
+ callId: 'tool-1',
+ error: new Error('Execution failed'),
+ errorType: ToolErrorType.EXECUTION_FAILED,
+ responseParts: [
+ {
+ functionResponse: {
+ name: 'errorTool',
+ response: {
+ output: 'Error: Execution failed',
+ },
+ },
+ },
+ ],
+ resultDisplay: 'Execution failed',
+ contentLength: undefined,
+ },
+ },
+ ]);
+ const finalResponse: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Content,
+ value: 'Sorry, let me try again.',
+ },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(createStreamFromEvents([toolCallEvent]))
+ .mockReturnValueOnce(createStreamFromEvents(finalResponse));
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Trigger tool error',
+ prompt_id: 'prompt-id-3',
+ });
+
+ expect(mockSchedulerSchedule).toHaveBeenCalled();
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Error executing tool errorTool: Execution failed',
+ );
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
+ 2,
+ [
+ {
+ functionResponse: {
+ name: 'errorTool',
+ response: {
+ output: 'Error: Execution failed',
+ },
+ },
+ },
+ ],
+ expect.any(AbortSignal),
+ 'prompt-id-3',
+ undefined,
+ undefined,
+ );
+ expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
+ });
+
+ it('should exit with error if sendMessageStream throws initially', async () => {
+ const apiError = new Error('API connection failed');
+ mockGeminiClient.sendMessageStream.mockImplementation(() => {
+ throw apiError;
+ });
+
+ await expect(
+ runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Initial fail',
+ prompt_id: 'prompt-id-4',
+ }),
+ ).rejects.toThrow(apiError);
+ });
+
+ it('should not exit if a tool is not found, and should send error back to model', async () => {
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'tool-1',
+ name: 'nonexistentTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-5',
+ },
+ };
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Error,
+ request: {
+ callId: 'tool-1',
+ name: 'nonexistentTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-5',
+ },
+ response: {
+ callId: 'tool-1',
+ error: new Error('Tool "nonexistentTool" not found in registry.'),
+ resultDisplay: 'Tool "nonexistentTool" not found in registry.',
+ responseParts: [],
+ errorType: undefined,
+ contentLength: undefined,
+ },
+ },
+ ]);
+ const finalResponse: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Content,
+ value: "Sorry, I can't find that tool.",
+ },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(createStreamFromEvents([toolCallEvent]))
+ .mockReturnValueOnce(createStreamFromEvents(finalResponse));
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Trigger tool not found',
+ prompt_id: 'prompt-id-5',
+ });
+
+ expect(mockSchedulerSchedule).toHaveBeenCalled();
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.',
+ );
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
+ expect(getWrittenOutput()).toBe("Sorry, I can't find that tool.\n");
+ });
+
+ it('should exit when max session turns are exceeded', async () => {
+ vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0);
+ await expect(
+ runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Trigger loop',
+ prompt_id: 'prompt-id-6',
+ }),
+ ).rejects.toThrow('process.exit(53) called');
+ });
+
+ it('should preprocess @include commands before sending to the model', async () => {
+ // 1. Mock the imported atCommandProcessor
+ const { handleAtCommand } = await import(
+ './ui/hooks/atCommandProcessor.js'
+ );
+ const mockHandleAtCommand = vi.mocked(handleAtCommand);
+
+ // 2. Define the raw input and the expected processed output
+ const rawInput = 'Summarize @file.txt';
+ const processedParts: Part[] = [
+ { text: 'Summarize @file.txt' },
+ { text: '\n--- Content from referenced files ---\n' },
+ { text: 'This is the content of the file.' },
+ { text: '\n--- End of content ---' },
+ ];
+
+ // 3. Setup the mock to return the processed parts
+ mockHandleAtCommand.mockResolvedValue({
+ processedQuery: processedParts,
+ });
+
+ // Mock a simple stream response from the Gemini client
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Summary complete.' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ // 4. Run the non-interactive mode with the raw input
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: rawInput,
+ prompt_id: 'prompt-id-7',
+ });
+
+ // 5. Assert that sendMessageStream was called with the PROCESSED parts, not the raw input
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
+ processedParts,
+ expect.any(AbortSignal),
+ 'prompt-id-7',
+ undefined,
+ rawInput,
+ );
+
+ // 6. Assert the final output is correct
+ expect(getWrittenOutput()).toBe('Summary complete.\n');
+ });
+
+ it('should process input and write JSON output with stats', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Hello World' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-1',
+ });
+
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
+ [{ text: 'Test input' }],
+ expect.any(AbortSignal),
+ 'prompt-id-1',
+ undefined,
+ 'Test input',
+ );
+ expect(processStdoutSpy).toHaveBeenCalledWith(
+ JSON.stringify(
+ {
+ session_id: 'test-session-id',
+ response: 'Hello World',
+ stats: MOCK_SESSION_METRICS,
+ },
+ null,
+ 2,
+ ),
+ );
+ });
+
+ it('should write JSON output with stats for tool-only commands (no text response)', async () => {
+ // Test the scenario where a command completes successfully with only tool calls
+ // but no text response - this would have caught the original bug
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'tool-1',
+ name: 'testTool',
+ args: { arg1: 'value1' },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-tool-only',
+ },
+ };
+ const toolResponse: Part[] = [{ text: 'Tool executed successfully' }];
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Success,
+ request: {
+ callId: 'tool-1',
+ name: 'testTool',
+ args: { arg1: 'value1' },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-tool-only',
+ },
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ responseParts: toolResponse,
+ callId: 'tool-1',
+ error: undefined,
+ errorType: undefined,
+ contentLength: undefined,
+ },
+ },
+ ]);
+
+ // First call returns only tool call, no content
+ const firstCallEvents: ServerGeminiStreamEvent[] = [
+ toolCallEvent,
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
+ },
+ ];
+
+ // Second call returns no content (tool-only completion)
+ const secondCallEvents: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
+ .mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
+
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Execute tool only',
+ prompt_id: 'prompt-id-tool-only',
+ });
+
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
+ expect(mockSchedulerSchedule).toHaveBeenCalledWith(
+ [expect.objectContaining({ name: 'testTool' })],
+ expect.any(AbortSignal),
+ );
+
+ // This should output JSON with empty response but include stats
+ expect(processStdoutSpy).toHaveBeenCalledWith(
+ JSON.stringify(
+ {
+ session_id: 'test-session-id',
+ response: '',
+ stats: MOCK_SESSION_METRICS,
+ },
+ null,
+ 2,
+ ),
+ );
+ });
+
+ it('should write JSON output with stats for empty response commands', async () => {
+ // Test the scenario where a command completes but produces no content at all
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Empty response test',
+ prompt_id: 'prompt-id-empty',
+ });
+
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
+ [{ text: 'Empty response test' }],
+ expect.any(AbortSignal),
+ 'prompt-id-empty',
+ undefined,
+ 'Empty response test',
+ );
+
+ // This should output JSON with empty response but include stats
+ expect(processStdoutSpy).toHaveBeenCalledWith(
+ JSON.stringify(
+ {
+ session_id: 'test-session-id',
+ response: '',
+ stats: MOCK_SESSION_METRICS,
+ },
+ null,
+ 2,
+ ),
+ );
+ });
+
+ it('should handle errors in JSON format', async () => {
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+ const testError = new Error('Invalid input provided');
+
+ mockGeminiClient.sendMessageStream.mockImplementation(() => {
+ throw testError;
+ });
+
+ let thrownError: Error | null = null;
+ try {
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-error',
+ });
+ // Should not reach here
+ expect.fail('Expected process.exit to be called');
+ } catch (error) {
+ thrownError = error as Error;
+ }
+
+ // Should throw because of mocked process.exit
+ expect(thrownError?.message).toBe('process.exit(1) called');
+
+ expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
+ 'error',
+ JSON.stringify(
+ {
+ session_id: 'test-session-id',
+ error: {
+ type: 'Error',
+ message: 'Invalid input provided',
+ code: 1,
+ },
+ },
+ null,
+ 2,
+ ),
+ );
+ });
+
+ it('should handle FatalInputError with custom exit code in JSON format', async () => {
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+ const fatalError = new FatalInputError('Invalid command syntax provided');
+
+ mockGeminiClient.sendMessageStream.mockImplementation(() => {
+ throw fatalError;
+ });
+
+ let thrownError: Error | null = null;
+ try {
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Invalid syntax',
+ prompt_id: 'prompt-id-fatal',
+ });
+ // Should not reach here
+ expect.fail('Expected process.exit to be called');
+ } catch (error) {
+ thrownError = error as Error;
+ }
+
+ // Should throw because of mocked process.exit with custom exit code
+ expect(thrownError?.message).toBe('process.exit(42) called');
+
+ expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
+ 'error',
+ JSON.stringify(
+ {
+ session_id: 'test-session-id',
+ error: {
+ type: 'FatalInputError',
+ message: 'Invalid command syntax provided',
+ code: 42,
+ },
+ },
+ null,
+ 2,
+ ),
+ );
+ });
+
+ it('should execute a slash command that returns a prompt', async () => {
+ const mockCommand = {
+ name: 'testcommand',
+ description: 'a test command',
+ action: vi.fn().mockResolvedValue({
+ type: 'submit_prompt',
+ content: [{ text: 'Prompt from command' }],
+ }),
+ };
+ mockGetCommands.mockReturnValue([mockCommand]);
+
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Response from command' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: '/testcommand',
+ prompt_id: 'prompt-id-slash',
+ });
+
+ // Ensure the prompt sent to the model is from the command, not the raw input
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
+ [{ text: 'Prompt from command' }],
+ expect.any(AbortSignal),
+ 'prompt-id-slash',
+ undefined,
+ '/testcommand',
+ );
+
+ expect(getWrittenOutput()).toBe('Response from command\n');
+ });
+
+ it('should handle slash commands', async () => {
+ const nonInteractiveCliCommands = await import(
+ './nonInteractiveCliCommands.js'
+ );
+ const handleSlashCommandSpy = vi.spyOn(
+ nonInteractiveCliCommands,
+ 'handleSlashCommand',
+ );
+ handleSlashCommandSpy.mockResolvedValue([{ text: 'Slash command output' }]);
+
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Response to slash command' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: '/help',
+ prompt_id: 'prompt-id-slash',
+ });
+
+ expect(handleSlashCommandSpy).toHaveBeenCalledWith(
+ '/help',
+ expect.any(AbortController),
+ mockConfig,
+ mockSettings,
+ );
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
+ [{ text: 'Slash command output' }],
+ expect.any(AbortSignal),
+ 'prompt-id-slash',
+ undefined,
+ '/help',
+ );
+ expect(getWrittenOutput()).toBe('Response to slash command\n');
+ handleSlashCommandSpy.mockRestore();
+ });
+
+ it('should handle cancellation (Ctrl+C)', async () => {
+ // Mock isTTY and setRawMode safely
+ const originalIsTTY = process.stdin.isTTY;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const originalSetRawMode = (process.stdin as any).setRawMode;
+
+ Object.defineProperty(process.stdin, 'isTTY', {
+ value: true,
+ configurable: true,
+ });
+ if (!originalSetRawMode) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (process.stdin as any).setRawMode = vi.fn();
+ }
+
+ const stdinOnSpy = vi
+ .spyOn(process.stdin, 'on')
+ .mockImplementation(() => process.stdin);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ vi.spyOn(process.stdin as any, 'setRawMode').mockImplementation(() => true);
+ vi.spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin);
+ vi.spyOn(process.stdin, 'pause').mockImplementation(() => process.stdin);
+ vi.spyOn(process.stdin, 'removeAllListeners').mockImplementation(
+ () => process.stdin,
+ );
+
+ // Spy on handleCancellationError to verify it's called
+ const errors = await import('./utils/errors.js');
+ const handleCancellationErrorSpy = vi
+ .spyOn(errors, 'handleCancellationError')
+ .mockImplementation(() => {
+ throw new Error('Cancelled');
+ });
+
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Thinking...' },
+ ];
+ // Create a stream that responds to abortion
+ mockGeminiClient.sendMessageStream.mockImplementation(
+ (_messages, signal: AbortSignal) =>
+ (async function* () {
+ yield events[0];
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(resolve, 1000);
+ signal.addEventListener('abort', () => {
+ clearTimeout(timeout);
+ setTimeout(() => {
+ reject(new Error('Aborted')); // This will be caught by nonInteractiveCli and passed to handleError
+ }, 300);
+ });
+ });
+ })(),
+ );
+
+ const runPromise = runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Long running query',
+ prompt_id: 'prompt-id-cancel',
+ });
+
+ // Wait a bit for setup to complete and listeners to be registered
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ // Find the keypress handler registered by runNonInteractive
+ const keypressCall = stdinOnSpy.mock.calls.find(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (call) => (call[0] as any) === 'keypress',
+ );
+ expect(keypressCall).toBeDefined();
+ const keypressHandler = keypressCall?.[1] as (
+ str: string,
+ key: { name?: string; ctrl?: boolean },
+ ) => void;
+
+ if (keypressHandler) {
+ // Simulate Ctrl+C
+ keypressHandler('\u0003', { ctrl: true, name: 'c' });
+ }
+
+ // The promise should reject with 'Aborted' because our mock stream throws it,
+ // and nonInteractiveCli catches it and calls handleError, which doesn't necessarily throw.
+ // Wait, if handleError is called, we should check that.
+ // But here we want to check if Ctrl+C works.
+
+ // In our current setup, Ctrl+C aborts the signal. The stream throws 'Aborted'.
+ // nonInteractiveCli catches 'Aborted' and calls handleError.
+
+ // If we want to test that handleCancellationError is called, we need the loop to detect abortion.
+ // But our stream throws before the loop can detect it.
+
+ // Let's just check that the promise rejects with 'Aborted' for now,
+ // which proves the abortion signal reached the stream.
+ await expect(runPromise).rejects.toThrow('Aborted');
+
+ expect(
+ processStderrSpy.mock.calls.some(
+ // eslint-disable-next-line no-restricted-syntax
+ (call) => typeof call[0] === 'string' && call[0].includes('Cancelling'),
+ ),
+ ).toBe(true);
+
+ handleCancellationErrorSpy.mockRestore();
+
+ // Restore original values
+ Object.defineProperty(process.stdin, 'isTTY', {
+ value: originalIsTTY,
+ configurable: true,
+ });
+ if (originalSetRawMode) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (process.stdin as any).setRawMode = originalSetRawMode;
+ } else {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ delete (process.stdin as any).setRawMode;
+ }
+ // Spies are automatically restored by vi.restoreAllMocks() in afterEach,
+ // but we can also do it manually if needed.
+ });
+
+ it('should throw FatalInputError if a command requires confirmation', async () => {
+ const mockCommand = {
+ name: 'confirm',
+ description: 'a command that needs confirmation',
+ action: vi.fn().mockResolvedValue({
+ type: 'confirm_shell_commands',
+ commands: ['rm -rf /'],
+ }),
+ };
+ mockGetCommands.mockReturnValue([mockCommand]);
+
+ await expect(
+ runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: '/confirm',
+ prompt_id: 'prompt-id-confirm',
+ }),
+ ).rejects.toThrow(
+ 'Exiting due to a confirmation prompt requested by the command.',
+ );
+ });
+
+ it('should treat an unknown slash command as a regular prompt', async () => {
+ // No commands are mocked, so any slash command is "unknown"
+ mockGetCommands.mockReturnValue([]);
+
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Response to unknown' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: '/unknowncommand',
+ prompt_id: 'prompt-id-unknown',
+ });
+
+ // Ensure the raw input is sent to the model
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
+ [{ text: '/unknowncommand' }],
+ expect.any(AbortSignal),
+ 'prompt-id-unknown',
+ undefined,
+ '/unknowncommand',
+ );
+
+ expect(getWrittenOutput()).toBe('Response to unknown\n');
+ });
+
+ it('should throw for unhandled command result types', async () => {
+ const mockCommand = {
+ name: 'noaction',
+ description: 'unhandled type',
+ action: vi.fn().mockResolvedValue({
+ type: 'unhandled',
+ }),
+ };
+ mockGetCommands.mockReturnValue([mockCommand]);
+
+ await expect(
+ runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: '/noaction',
+ prompt_id: 'prompt-id-unhandled',
+ }),
+ ).rejects.toThrow(
+ 'Exiting due to command result that is not supported in non-interactive mode.',
+ );
+ });
+
+ it('should pass arguments to the slash command action', async () => {
+ const mockAction = vi.fn().mockResolvedValue({
+ type: 'submit_prompt',
+ content: [{ text: 'Prompt from command' }],
+ });
+ const mockCommand = {
+ name: 'testargs',
+ description: 'a test command',
+ action: mockAction,
+ };
+ mockGetCommands.mockReturnValue([mockCommand]);
+
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Acknowledged' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: '/testargs arg1 arg2',
+ prompt_id: 'prompt-id-args',
+ });
+
+ expect(mockAction).toHaveBeenCalledWith(expect.any(Object), 'arg1 arg2');
+
+ expect(getWrittenOutput()).toBe('Acknowledged\n');
+ });
+
+ it('should instantiate CommandService with correct loaders for slash commands', async () => {
+ // This test indirectly checks that handleSlashCommand is using the right loaders.
+ const { FileCommandLoader } = await import(
+ './services/FileCommandLoader.js'
+ );
+ const { McpPromptLoader } = await import('./services/McpPromptLoader.js');
+ const { BuiltinCommandLoader } = await import(
+ './services/BuiltinCommandLoader.js'
+ );
+ mockGetCommands.mockReturnValue([]); // No commands found, so it will fall through
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Acknowledged' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: '/mycommand',
+ prompt_id: 'prompt-id-loaders',
+ });
+
+ // Check that loaders were instantiated with the config
+ expect(FileCommandLoader).toHaveBeenCalledTimes(1);
+ expect(FileCommandLoader).toHaveBeenCalledWith(mockConfig);
+ expect(McpPromptLoader).toHaveBeenCalledTimes(1);
+ expect(McpPromptLoader).toHaveBeenCalledWith(mockConfig);
+ expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig);
+
+ // Check that instances were passed to CommandService.create
+ expect(mockCommandServiceCreate).toHaveBeenCalledTimes(1);
+ const loadersArg = mockCommandServiceCreate.mock.calls[0][0];
+ expect(loadersArg).toHaveLength(3);
+ expect(loadersArg[0]).toBe(
+ vi.mocked(BuiltinCommandLoader).mock.instances[0],
+ );
+ expect(loadersArg[1]).toBe(vi.mocked(McpPromptLoader).mock.instances[0]);
+ expect(loadersArg[2]).toBe(vi.mocked(FileCommandLoader).mock.instances[0]);
+ });
+
+ it('should allow a normally-excluded tool when --allowed-tools is set', async () => {
+ // By default, ShellTool is excluded in non-interactive mode.
+ // This test ensures that --allowed-tools overrides this exclusion.
+ vi.mocked(mockConfig.getToolRegistry).mockReturnValue({
+ getTool: vi.fn().mockReturnValue({
+ name: 'ShellTool',
+ description: 'A shell tool',
+ run: vi.fn(),
+ }),
+ getFunctionDeclarations: vi.fn().mockReturnValue([{ name: 'ShellTool' }]),
+ } as unknown as ToolRegistry);
+
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'tool-shell-1',
+ name: 'ShellTool',
+ args: { command: 'ls' },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-allowed',
+ },
+ };
+ const toolResponse: Part[] = [{ text: 'file.txt' }];
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Success,
+ request: {
+ callId: 'tool-shell-1',
+ name: 'ShellTool',
+ args: { command: 'ls' },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-allowed',
+ },
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ responseParts: toolResponse,
+ callId: 'tool-shell-1',
+ error: undefined,
+ errorType: undefined,
+ contentLength: undefined,
+ },
+ },
+ ]);
+
+ const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
+ const secondCallEvents: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'file.txt' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
+ .mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'List the files',
+ prompt_id: 'prompt-id-allowed',
+ });
+
+ expect(mockSchedulerSchedule).toHaveBeenCalledWith(
+ [expect.objectContaining({ name: 'ShellTool' })],
+ expect.any(AbortSignal),
+ );
+ expect(getWrittenOutput()).toBe('file.txt\n');
+ });
+
+ describe('CoreEvents Integration', () => {
+ it('subscribes to UserFeedback and drains backlog on start', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test',
+ prompt_id: 'prompt-id-events',
+ });
+
+ expect(mockCoreEvents.on).toHaveBeenCalledWith(
+ CoreEvent.UserFeedback,
+ expect.any(Function),
+ );
+ expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1);
+ });
+
+ it('unsubscribes from UserFeedback on finish', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test',
+ prompt_id: 'prompt-id-events',
+ });
+
+ expect(mockCoreEvents.off).toHaveBeenCalledWith(
+ CoreEvent.UserFeedback,
+ expect.any(Function),
+ );
+ });
+
+ it('logs to process.stderr when UserFeedback event is received', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test',
+ prompt_id: 'prompt-id-events',
+ });
+
+ // Get the registered handler
+ const handler = mockCoreEvents.on.mock.calls.find(
+ (call: unknown[]) => call[0] === CoreEvent.UserFeedback,
+ )?.[1];
+ expect(handler).toBeDefined();
+
+ // Simulate an event
+ const payload: UserFeedbackPayload = {
+ severity: 'error',
+ message: 'Test error message',
+ };
+ handler(payload);
+
+ expect(processStderrSpy).toHaveBeenCalledWith(
+ '[ERROR] Test error message\n',
+ );
+ });
+
+ it('logs optional error object to process.stderr in debug mode', async () => {
+ vi.mocked(mockConfig.getDebugMode).mockReturnValue(true);
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test',
+ prompt_id: 'prompt-id-events',
+ });
+
+ // Get the registered handler
+ const handler = mockCoreEvents.on.mock.calls.find(
+ (call: unknown[]) => call[0] === CoreEvent.UserFeedback,
+ )?.[1];
+ expect(handler).toBeDefined();
+
+ // Simulate an event with error object
+ const errorObj = new Error('Original error');
+ // Mock stack for deterministic testing
+ errorObj.stack = 'Error: Original error\n at test';
+ const payload: UserFeedbackPayload = {
+ severity: 'warning',
+ message: 'Test warning message',
+ error: errorObj,
+ };
+ handler(payload);
+
+ expect(processStderrSpy).toHaveBeenCalledWith(
+ '[WARNING] Test warning message\n',
+ );
+ expect(processStderrSpy).toHaveBeenCalledWith(
+ 'Error: Original error\n at test\n',
+ );
+ });
+ });
+
+ it('should emit appropriate events for streaming JSON output', async () => {
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
+ OutputFormat.STREAM_JSON,
+ );
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'tool-1',
+ name: 'testTool',
+ args: { arg1: 'value1' },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-stream',
+ },
+ };
+
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Success,
+ request: toolCallEvent.value,
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ responseParts: [{ text: 'Tool response' }],
+ callId: 'tool-1',
+ error: undefined,
+ errorType: undefined,
+ contentLength: undefined,
+ resultDisplay: 'Tool executed successfully',
+ },
+ },
+ ]);
+
+ const firstCallEvents: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Thinking...' },
+ toolCallEvent,
+ ];
+ const secondCallEvents: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Final answer' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
+ .mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Stream test',
+ prompt_id: 'prompt-id-stream',
+ });
+
+ const output = getWrittenOutput();
+ const sanitizedOutput = output
+ .replace(/"timestamp":"[^"]+"/g, '"timestamp":""')
+ .replace(/"duration_ms":\d+/g, '"duration_ms":');
+ expect(sanitizedOutput).toMatchSnapshot();
+ });
+
+ it('should handle EPIPE error gracefully', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Hello' },
+ { type: GeminiEventType.Content, value: ' World' },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ // Mock process.exit to track calls without throwing
+ vi.spyOn(process, 'exit').mockImplementation((_code) => undefined as never);
+
+ // Simulate EPIPE error on stdout
+ const stdoutErrorCallback = (process.stdout.on as Mock).mock.calls.find(
+ (call) => call[0] === 'error',
+ )?.[1];
+
+ if (stdoutErrorCallback) {
+ stdoutErrorCallback({ code: 'EPIPE' });
+ }
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'EPIPE test',
+ prompt_id: 'prompt-id-epipe',
+ });
+
+ // Since EPIPE is simulated, it might exit early or continue depending on timing,
+ // but our main goal is to verify the handler is registered and handles EPIPE.
+ expect(process.stdout.on).toHaveBeenCalledWith(
+ 'error',
+ expect.any(Function),
+ );
+ });
+
+ it('should resume chat when resumedSessionData is provided', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Resumed' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ const resumedSessionData = {
+ conversation: {
+ sessionId: 'resumed-session-id',
+ messages: [
+ { role: 'user', parts: [{ text: 'Previous message' }] },
+ ] as any, // eslint-disable-line @typescript-eslint/no-explicit-any
+ startTime: new Date().toISOString(),
+ lastUpdated: new Date().toISOString(),
+ firstUserMessage: 'Previous message',
+ projectHash: 'test-hash',
+ },
+ filePath: '/path/to/session.json',
+ };
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Continue',
+ prompt_id: 'prompt-id-resume',
+ resumedSessionData,
+ });
+
+ expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith(
+ expect.any(Array),
+ resumedSessionData,
+ );
+ expect(getWrittenOutput()).toBe('Resumed\n');
+ });
+
+ it.each([
+ {
+ name: 'loop detected',
+ events: [
+ { type: GeminiEventType.LoopDetected },
+ ] as ServerGeminiStreamEvent[],
+ input: 'Loop test',
+ promptId: 'prompt-id-loop',
+ },
+ {
+ name: 'max session turns',
+ events: [
+ { type: GeminiEventType.MaxSessionTurns },
+ ] as ServerGeminiStreamEvent[],
+ input: 'Max turns test',
+ promptId: 'prompt-id-max-turns',
+ },
+ ])(
+ 'should emit appropriate error event in streaming JSON mode: $name',
+ async ({ events, input, promptId }) => {
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
+ OutputFormat.STREAM_JSON,
+ );
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ const streamEvents: ServerGeminiStreamEvent[] = [
+ ...events,
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(streamEvents),
+ );
+
+ try {
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input,
+ prompt_id: promptId,
+ });
+ } catch {
+ // Expected exit
+ }
+
+ const output = getWrittenOutput();
+ const sanitizedOutput = output
+ .replace(/"timestamp":"[^"]+"/g, '"timestamp":""')
+ .replace(/"duration_ms":\d+/g, '"duration_ms":');
+ expect(sanitizedOutput).toMatchSnapshot();
+ },
+ );
+
+ it.each([
+ {
+ name: 'loop detected',
+ events: [
+ { type: GeminiEventType.LoopDetected },
+ ] as ServerGeminiStreamEvent[],
+ expectedWarning: 'Loop detected, stopping execution',
+ },
+ {
+ name: 'max session turns',
+ events: [
+ { type: GeminiEventType.MaxSessionTurns },
+ ] as ServerGeminiStreamEvent[],
+ expectedWarning: 'Maximum session turns exceeded',
+ },
+ ])(
+ 'should include warning in JSON mode for: $name',
+ async ({ events, expectedWarning }) => {
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ const streamEvents: ServerGeminiStreamEvent[] = [
+ ...events,
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(streamEvents),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test',
+ prompt_id: 'test',
+ });
+
+ const output = JSON.parse(getWrittenOutput());
+ expect(output.warnings).toBeDefined();
+ expect(output.warnings).toContain(expectedWarning);
+ },
+ );
+
+ it('should log error when tool recording fails', async () => {
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'tool-1',
+ name: 'testTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-tool-error',
+ },
+ };
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Success,
+ request: toolCallEvent.value,
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ responseParts: [],
+ callId: 'tool-1',
+ error: undefined,
+ errorType: undefined,
+ contentLength: undefined,
+ },
+ },
+ ]);
+
+ const events: ServerGeminiStreamEvent[] = [
+ toolCallEvent,
+ { type: GeminiEventType.Content, value: 'Done' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(createStreamFromEvents(events))
+ .mockReturnValueOnce(
+ createStreamFromEvents([
+ { type: GeminiEventType.Content, value: 'Done' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
+ },
+ ]),
+ );
+
+ // Mock getChat to throw when recording tool calls
+ const mockChat = {
+ recordCompletedToolCalls: vi.fn().mockImplementation(() => {
+ throw new Error('Recording failed');
+ }),
+ };
+ // @ts-expect-error - Mocking internal structure
+ mockGeminiClient.getChat = vi.fn().mockReturnValue(mockChat);
+ mockGeminiClient.getCurrentSequenceModel = vi
+ .fn()
+ .mockReturnValue('model-1');
+
+ // Mock debugLogger.error
+ const { debugLogger } = await import('@google/gemini-cli-core');
+ const debugLoggerErrorSpy = vi
+ .spyOn(debugLogger, 'error')
+ .mockImplementation(() => {});
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Tool recording error test',
+ prompt_id: 'prompt-id-tool-error',
+ });
+
+ expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'Error recording completed tool call information: Error: Recording failed',
+ ),
+ );
+ expect(getWrittenOutput()).toContain('Done');
+ });
+
+ it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => {
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'stop-call',
+ name: 'stopTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-stop',
+ },
+ };
+
+ // Mock tool execution returning STOP_EXECUTION
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Error,
+ request: toolCallEvent.value,
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ callId: 'stop-call',
+ responseParts: [{ text: 'error occurred' }],
+ errorType: ToolErrorType.STOP_EXECUTION,
+ error: new Error('Stop reason from hook'),
+ resultDisplay: undefined,
+ },
+ },
+ ]);
+
+ const firstCallEvents: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Executing tool...' },
+ toolCallEvent,
+ ];
+
+ // Setup the mock to return events for the first call.
+ // We expect the loop to terminate after the tool execution.
+ // If it doesn't, it might call sendMessageStream again, which we'll assert against.
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
+ .mockReturnValueOnce(createStreamFromEvents([]));
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Run stop tool',
+ prompt_id: 'prompt-id-stop',
+ });
+
+ expect(mockSchedulerSchedule).toHaveBeenCalled();
+
+ // The key assertion: sendMessageStream should have been called ONLY ONCE (initial user input).
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
+
+ expect(processStderrSpy).toHaveBeenCalledWith(
+ 'Agent execution stopped: Stop reason from hook\n',
+ );
+ });
+
+ it('should write JSON output when a tool call returns STOP_EXECUTION error', async () => {
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'stop-call',
+ name: 'stopTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-stop-json',
+ },
+ };
+
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Error,
+ request: toolCallEvent.value,
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ callId: 'stop-call',
+ responseParts: [{ text: 'error occurred' }],
+ errorType: ToolErrorType.STOP_EXECUTION,
+ error: new Error('Stop reason'),
+ resultDisplay: undefined,
+ },
+ },
+ ]);
+
+ const firstCallEvents: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Partial content' },
+ toolCallEvent,
+ ];
+
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(firstCallEvents),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Run stop tool',
+ prompt_id: 'prompt-id-stop-json',
+ });
+
+ expect(processStdoutSpy).toHaveBeenCalledWith(
+ JSON.stringify(
+ {
+ session_id: 'test-session-id',
+ response: 'Partial content',
+ stats: MOCK_SESSION_METRICS,
+ },
+ null,
+ 2,
+ ),
+ );
+ });
+
+ it('should emit result event when a tool call returns STOP_EXECUTION error in streaming JSON mode', async () => {
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
+ OutputFormat.STREAM_JSON,
+ );
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'stop-call',
+ name: 'stopTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-stop-stream',
+ },
+ };
+
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Error,
+ request: toolCallEvent.value,
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ callId: 'stop-call',
+ responseParts: [{ text: 'error occurred' }],
+ errorType: ToolErrorType.STOP_EXECUTION,
+ error: new Error('Stop reason'),
+ resultDisplay: undefined,
+ },
+ },
+ ]);
+
+ const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
+
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(firstCallEvents),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Run stop tool',
+ prompt_id: 'prompt-id-stop-stream',
+ });
+
+ const output = getWrittenOutput();
+ expect(output).toContain('"type":"result"');
+ expect(output).toContain('"status":"success"');
+ });
+
+ describe('Agent Execution Events', () => {
+ it('should handle AgentExecutionStopped event', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.AgentExecutionStopped,
+ value: { reason: 'Stopped by hook' },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test stop',
+ prompt_id: 'prompt-id-stop',
+ });
+
+ expect(processStderrSpy).toHaveBeenCalledWith(
+ 'Agent execution stopped: Stopped by hook\n',
+ );
+ // Should exit without calling sendMessageStream again
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
+ });
+
+ it('should write JSON output when AgentExecutionStopped event occurs', async () => {
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Partial content' },
+ {
+ type: GeminiEventType.AgentExecutionStopped,
+ value: { reason: 'Stopped by hook' },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test stop',
+ prompt_id: 'prompt-id-stop-json',
+ });
+
+ expect(processStdoutSpy).toHaveBeenCalledWith(
+ JSON.stringify(
+ {
+ session_id: 'test-session-id',
+ response: 'Partial content',
+ stats: MOCK_SESSION_METRICS,
+ warnings: ['Agent execution stopped: Stopped by hook'],
+ },
+ null,
+ 2,
+ ),
+ );
+ });
+
+ it('should emit result event when AgentExecutionStopped event occurs in streaming JSON mode', async () => {
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
+ OutputFormat.STREAM_JSON,
+ );
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Partial content' },
+ {
+ type: GeminiEventType.AgentExecutionStopped,
+ value: { reason: 'Stopped by hook' },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test stop',
+ prompt_id: 'prompt-id-stop-stream',
+ });
+
+ const output = getWrittenOutput();
+ expect(output).toContain('"type":"result"');
+ expect(output).toContain('"status":"success"');
+ });
+
+ it('should handle AgentExecutionBlocked event', async () => {
+ const allEvents: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.AgentExecutionBlocked,
+ value: { reason: 'Blocked by hook' },
+ },
+ { type: GeminiEventType.Content, value: 'Final answer' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(allEvents),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test block',
+ prompt_id: 'prompt-id-block',
+ });
+
+ expect(processStderrSpy).toHaveBeenCalledWith(
+ '[WARNING] Agent execution blocked: Blocked by hook\n',
+ );
+ // sendMessageStream is called once, recursion is internal to it and transparent to the caller
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
+ expect(getWrittenOutput()).toBe('Final answer\n');
+ });
+
+ it('should emit ERROR event in STREAM_JSON mode when AgentExecutionBlocked occurs', async () => {
+ const allEvents: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.AgentExecutionBlocked,
+ value: { reason: 'Blocked by hook' },
+ },
+ { type: GeminiEventType.Content, value: 'Final answer' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(allEvents),
+ );
+
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ // Setup stream-json format
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
+ OutputFormat.STREAM_JSON,
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test block',
+ prompt_id: 'prompt-id-block',
+ });
+
+ const calls = processStdoutSpy.mock.calls.map((call) =>
+ JSON.parse(call[0] as string),
+ );
+ const errorEvent = calls.find(
+ (c) => c.type === JsonStreamEventType.ERROR,
+ );
+
+ expect(errorEvent).toBeDefined();
+ expect(errorEvent.message).toContain(
+ 'Agent execution blocked: Blocked by hook',
+ );
+ expect(errorEvent.severity).toBe('warning');
+ });
+
+ it('should include warning in JSON mode when AgentExecutionBlocked occurs', async () => {
+ const allEvents: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.AgentExecutionBlocked,
+ value: { reason: 'Blocked by hook' },
+ },
+ { type: GeminiEventType.Content, value: 'Final answer' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(allEvents),
+ );
+
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test block',
+ prompt_id: 'prompt-id-block',
+ });
+
+ const output = JSON.parse(getWrittenOutput());
+ expect(output.warnings).toBeDefined();
+ expect(output.warnings).toContain(
+ 'Agent execution blocked: Blocked by hook',
+ );
+ });
+
+ it('should handle multiple AgentExecutionBlocked events and collect all warnings', async () => {
+ const allEvents: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.AgentExecutionBlocked,
+ value: { reason: 'Block 1', systemMessage: 'Reason 1' },
+ },
+ {
+ type: GeminiEventType.AgentExecutionBlocked,
+ value: { reason: 'Block 2', systemMessage: 'Reason 2' },
+ },
+ { type: GeminiEventType.Content, value: 'Final answer' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream.mockImplementation(() =>
+ createStreamFromEvents(allEvents),
+ );
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test',
+ prompt_id: 'test',
+ });
+
+ const output = JSON.parse(getWrittenOutput());
+ expect(output.warnings).toHaveLength(2);
+ expect(output.warnings).toContain('Agent execution blocked: Reason 1');
+ expect(output.warnings).toContain('Agent execution blocked: Reason 2');
+ });
+
+ it('should not include warnings field in JSON output if no blocks occur', async () => {
+ const allEvents: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Clean answer' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream.mockImplementation(() =>
+ createStreamFromEvents(allEvents),
+ );
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test',
+ prompt_id: 'test',
+ });
+
+ const output = JSON.parse(getWrittenOutput());
+ expect(output.warnings).toBeUndefined();
+ });
+
+ it('should handle InvalidStream event gracefully in TEXT mode', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.InvalidStream,
+ value: {
+ type: 'NO_RESPONSE_TEXT',
+ message: 'Empty response',
+ },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test invalid stream',
+ prompt_id: 'prompt-id-invalid',
+ });
+
+ expect(processStderrSpy).toHaveBeenCalledWith(
+ `[ERROR] ${TRUE_EMPTY_RESPONSE_MESSAGE}\n`,
+ );
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle InvalidStream event gracefully in STREAM_JSON mode', async () => {
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+ vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue(
+ OutputFormat.STREAM_JSON,
+ );
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.InvalidStream,
+ value: {
+ type: 'NO_RESPONSE_TEXT',
+ message: 'Empty response',
+ },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test invalid stream',
+ prompt_id: 'prompt-id-invalid',
+ });
+
+ const output = getWrittenOutput();
+ expect(output).toContain('"type":"error"');
+ expect(output).toContain('"severity":"error"');
+ expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle InvalidStream event gracefully in JSON mode', async () => {
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+ vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue(
+ OutputFormat.JSON,
+ );
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.InvalidStream,
+ value: {
+ type: 'NO_RESPONSE_TEXT',
+ message: 'Empty response',
+ },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test invalid stream',
+ prompt_id: 'prompt-id-invalid',
+ });
+
+ const output = getWrittenOutput();
+ expect(output).toContain('"error": {');
+ expect(output).toContain('"type": "INVALID_STREAM"');
+ expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle non-NO_RESPONSE_TEXT InvalidStream event gracefully and use message from eventValue', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.InvalidStream,
+ value: {
+ type: 'MALFORMED_FUNCTION_CALL',
+ message: 'Custom malformed function call message',
+ },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test invalid stream malformed',
+ prompt_id: 'prompt-id-invalid-malformed',
+ });
+
+ expect(processStderrSpy).toHaveBeenCalledWith(
+ '[ERROR] Custom malformed function call message\n',
+ );
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Output Sanitization', () => {
+ const ANSI_SEQUENCE = '\u001B[31mRed Text\u001B[0m';
+ const OSC_HYPERLINK =
+ '\u001B]8;;http://example.com\u001B\\Link\u001B]8;;\u001B\\';
+ const PLAIN_TEXT_RED = 'Red Text';
+ const PLAIN_TEXT_LINK = 'Link';
+
+ it('should sanitize ANSI output by default', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: ANSI_SEQUENCE },
+ { type: GeminiEventType.Content, value: ' ' },
+ { type: GeminiEventType.Content, value: OSC_HYPERLINK },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ vi.mocked(mockConfig.getRawOutput).mockReturnValue(false);
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-sanitization',
+ });
+
+ expect(getWrittenOutput()).toBe(`${PLAIN_TEXT_RED} ${PLAIN_TEXT_LINK}\n`);
+ });
+
+ it('should allow ANSI output when rawOutput is true', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: ANSI_SEQUENCE },
+ { type: GeminiEventType.Content, value: ' ' },
+ { type: GeminiEventType.Content, value: OSC_HYPERLINK },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ vi.mocked(mockConfig.getRawOutput).mockReturnValue(true);
+ vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true);
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-raw',
+ });
+
+ expect(getWrittenOutput()).toBe(`${ANSI_SEQUENCE} ${OSC_HYPERLINK}\n`);
+ });
+
+ it('should allow ANSI output when only acceptRawOutputRisk is true', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: ANSI_SEQUENCE },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ vi.mocked(mockConfig.getRawOutput).mockReturnValue(false);
+ vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true);
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-accept-only',
+ });
+
+ expect(getWrittenOutput()).toBe(`${ANSI_SEQUENCE}\n`);
+ });
+
+ it('should warn when rawOutput is true and acceptRisk is false', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ vi.mocked(mockConfig.getRawOutput).mockReturnValue(true);
+ vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(false);
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-warn',
+ });
+
+ expect(processStderrSpy).toHaveBeenCalledWith(
+ expect.stringContaining('[WARNING] --raw-output is enabled'),
+ );
+ });
+
+ it('should not warn when rawOutput is true and acceptRisk is true', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ vi.mocked(mockConfig.getRawOutput).mockReturnValue(true);
+ vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true);
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-no-warn',
+ });
+
+ expect(processStderrSpy).not.toHaveBeenCalledWith(
+ expect.stringContaining('[WARNING] --raw-output is enabled'),
+ );
+ });
+
+ it('should report cancelled tool calls as success in stream-json mode (legacy parity)', async () => {
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'tool-1',
+ name: 'testTool',
+ args: { arg1: 'value1' },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-cancel',
+ },
+ };
+
+ // Mock the scheduler to return a cancelled status
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Cancelled,
+ request: toolCallEvent.value,
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ callId: 'tool-1',
+ responseParts: [{ text: 'Operation cancelled' }],
+ resultDisplay: 'Cancelled',
+ },
+ },
+ ]);
+
+ const events: ServerGeminiStreamEvent[] = [
+ toolCallEvent,
+ {
+ type: GeminiEventType.Content,
+ value: 'Model continues...',
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
+ OutputFormat.STREAM_JSON,
+ );
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-cancel',
+ });
+
+ const output = getWrittenOutput();
+ expect(output).toContain('"type":"tool_result"');
+ expect(output).toContain('"status":"success"');
+ });
+ });
+});
diff --git a/packages/cli/src/nonInteractiveCliAgentSession.test.ts b/packages/cli/src/nonInteractiveCliAgentSession.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..93fdc0b6b718ef7bb4ab18f23f9c778ac7e547c1
--- /dev/null
+++ b/packages/cli/src/nonInteractiveCliAgentSession.test.ts
@@ -0,0 +1,2823 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {
+ Config,
+ ToolRegistry,
+ ServerGeminiStreamEvent,
+ SessionMetrics,
+ AnyDeclarativeTool,
+ AnyToolInvocation,
+ UserFeedbackPayload,
+} from '@google/gemini-cli-core';
+import {
+ ToolErrorType,
+ GeminiEventType,
+ OutputFormat,
+ uiTelemetryService,
+ FatalInputError,
+ CoreEvent,
+ CoreToolCallStatus,
+ JsonStreamEventType,
+ TRUE_EMPTY_RESPONSE_MESSAGE,
+} from '@google/gemini-cli-core';
+import type { Part } from '@google/genai';
+import { runNonInteractive } from './nonInteractiveCliAgentSession.js';
+import {
+ describe,
+ it,
+ expect,
+ beforeEach,
+ afterEach,
+ vi,
+ type Mock,
+ type MockInstance,
+} from 'vitest';
+import type { LoadedSettings } from './config/settings.js';
+
+// Mock core modules
+vi.mock('./ui/hooks/atCommandProcessor.js');
+
+const mockSetupInitialActivityLogger = vi.hoisted(() => vi.fn());
+vi.mock('./utils/devtoolsService.js', () => ({
+ setupInitialActivityLogger: mockSetupInitialActivityLogger,
+}));
+
+const mockCoreEvents = vi.hoisted(() => ({
+ on: vi.fn(),
+ off: vi.fn(),
+ emit: vi.fn(),
+ emitConsoleLog: vi.fn(),
+ emitFeedback: vi.fn(),
+ drainBacklogs: vi.fn(),
+}));
+
+const mockSchedulerSchedule = vi.hoisted(() => vi.fn());
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const original =
+ await importOriginal();
+
+ class MockChatRecordingService {
+ initialize = vi.fn();
+ recordMessage = vi.fn();
+ recordMessageTokens = vi.fn();
+ recordToolCalls = vi.fn();
+ }
+
+ return {
+ ...original,
+ Scheduler: class {
+ schedule = mockSchedulerSchedule;
+ cancelAll = vi.fn();
+ dispose = vi.fn();
+ },
+ isTelemetrySdkInitialized: vi.fn().mockReturnValue(true),
+ ChatRecordingService: MockChatRecordingService,
+ uiTelemetryService: {
+ getMetrics: vi.fn(),
+ recordSemanticValidationError: vi.fn(),
+ },
+ LegacyAgentSession: original.LegacyAgentSession,
+ geminiPartsToContentParts: original.geminiPartsToContentParts,
+ coreEvents: mockCoreEvents,
+ createWorkingStdio: vi.fn(() => ({
+ stdout: process.stdout,
+ stderr: process.stderr,
+ })),
+ };
+});
+
+const mockGetCommands = vi.hoisted(() => vi.fn());
+const mockCommandServiceCreate = vi.hoisted(() => vi.fn());
+vi.mock('./services/CommandService.js', () => ({
+ CommandService: {
+ create: mockCommandServiceCreate,
+ },
+}));
+
+vi.mock('./services/FileCommandLoader.js');
+vi.mock('./services/McpPromptLoader.js');
+vi.mock('./services/BuiltinCommandLoader.js');
+
+describe('runNonInteractive', () => {
+ let mockConfig: Config;
+ let mockSettings: LoadedSettings;
+ let mockToolRegistry: ToolRegistry;
+ let consoleErrorSpy: MockInstance;
+ let processStdoutSpy: MockInstance;
+ let processStderrSpy: MockInstance;
+ let mockGeminiClient: {
+ sendMessageStream: Mock;
+ resumeChat: Mock;
+ getChatRecordingService: Mock;
+ getChat: Mock;
+ getCurrentSequenceModel: Mock;
+ };
+ const MOCK_SESSION_METRICS: SessionMetrics = {
+ models: {},
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: {
+ accept: 0,
+ reject: 0,
+ modify: 0,
+ auto_accept: 0,
+ },
+ byName: {},
+ },
+ files: {
+ totalLinesAdded: 0,
+ totalLinesRemoved: 0,
+ },
+ };
+
+ beforeEach(async () => {
+ mockSchedulerSchedule.mockReset();
+
+ mockCommandServiceCreate.mockResolvedValue({
+ getCommands: mockGetCommands,
+ });
+
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ processStdoutSpy = vi
+ .spyOn(process.stdout, 'write')
+ .mockImplementation(() => true);
+ vi.spyOn(process.stdout, 'on').mockImplementation(() => process.stdout);
+ processStderrSpy = vi
+ .spyOn(process.stderr, 'write')
+ .mockImplementation(() => true);
+ vi.spyOn(process, 'exit').mockImplementation((code) => {
+ throw new Error(`process.exit(${code}) called`);
+ });
+
+ mockToolRegistry = {
+ getTool: vi.fn(),
+ getFunctionDeclarations: vi.fn().mockReturnValue([]),
+ } as unknown as ToolRegistry;
+
+ mockGeminiClient = {
+ sendMessageStream: vi.fn(),
+ resumeChat: vi.fn().mockResolvedValue(undefined),
+ getChatRecordingService: vi.fn(() => ({
+ initialize: vi.fn(),
+ recordMessage: vi.fn(),
+ recordMessageTokens: vi.fn(),
+ recordToolCalls: vi.fn(),
+ })),
+ getChat: vi.fn(() => ({ recordCompletedToolCalls: vi.fn() })),
+ getCurrentSequenceModel: vi.fn().mockReturnValue(null),
+ };
+
+ mockConfig = {
+ initialize: vi.fn().mockReturnValue(Promise.resolve(undefined)),
+ getMessageBus: vi.fn().mockReturnValue({
+ subscribe: vi.fn(),
+ unsubscribe: vi.fn(),
+ publish: vi.fn(),
+ }),
+ getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
+ getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
+ getMaxSessionTurns: vi.fn().mockReturnValue(10),
+ getSessionId: vi.fn().mockReturnValue('test-session-id'),
+ getProjectRoot: vi.fn().mockReturnValue('/test/project'),
+ storage: {
+ getProjectTempDir: vi.fn().mockReturnValue('/test/project/.gemini/tmp'),
+ },
+ getIdeMode: vi.fn().mockReturnValue(false),
+
+ getContentGeneratorConfig: vi.fn().mockReturnValue({}),
+ getDebugMode: vi.fn().mockReturnValue(false),
+ getOutputFormat: vi.fn().mockReturnValue('text'),
+ getModel: vi.fn().mockReturnValue('test-model'),
+ getFolderTrust: vi.fn().mockReturnValue(false),
+ isTrustedFolder: vi.fn().mockReturnValue(false),
+ getRawOutput: vi.fn().mockReturnValue(false),
+ getAcceptRawOutputRisk: vi.fn().mockReturnValue(false),
+ getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false),
+ getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
+ } as unknown as Config;
+
+ mockSettings = {
+ system: { path: '', settings: {} },
+ systemDefaults: { path: '', settings: {} },
+ user: { path: '', settings: {} },
+ workspace: { path: '', settings: {} },
+ errors: [],
+ setValue: vi.fn(),
+ merged: {
+ security: {
+ auth: {
+ enforcedType: undefined,
+ },
+ },
+ },
+ isTrusted: true,
+ migratedInMemoryScopes: new Set(),
+ forScope: vi.fn(),
+ computeMergedSettings: vi.fn(),
+ } as unknown as LoadedSettings;
+
+ const { handleAtCommand } = await import(
+ './ui/hooks/atCommandProcessor.js'
+ );
+ vi.mocked(handleAtCommand).mockImplementation(async ({ query }) => ({
+ processedQuery: [{ text: query }],
+ }));
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ async function* createStreamFromEvents(
+ events: ServerGeminiStreamEvent[],
+ ): AsyncGenerator {
+ for (const event of events) {
+ yield event;
+ }
+ }
+
+ const getWrittenOutput = () =>
+ processStdoutSpy.mock.calls.map((c) => c[0]).join('');
+
+ it('should process input and write text output', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Hello' },
+ { type: GeminiEventType.Content, value: ' World' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-1',
+ });
+
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
+ [{ text: 'Test input' }],
+ expect.any(AbortSignal),
+ 'prompt-id-1',
+ undefined,
+ 'Test input',
+ );
+ expect(getWrittenOutput()).toBe('Hello World\n');
+ // Note: Telemetry shutdown is now handled in runExitCleanup() in cleanup.ts
+ // so we no longer expect shutdownTelemetry to be called directly here
+ });
+
+ it('should stream the specific stream started by send', async () => {
+ const { LegacyAgentSession } = await import('@google/gemini-cli-core');
+ const streamSpy = vi.spyOn(LegacyAgentSession.prototype, 'stream');
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Hello again' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-stream',
+ });
+
+ expect(streamSpy).toHaveBeenCalledWith({ streamId: expect.any(String) });
+ });
+
+ it('fails fast if the session acknowledges a message send without a stream', async () => {
+ const { LegacyAgentSession } = await import('@google/gemini-cli-core');
+ const sendSpy = vi
+ .spyOn(LegacyAgentSession.prototype, 'send')
+ .mockResolvedValue({ streamId: null });
+ const streamSpy = vi.spyOn(LegacyAgentSession.prototype, 'stream');
+
+ await expect(
+ runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-null-stream',
+ }),
+ ).rejects.toThrow(
+ 'LegacyAgentSession.send() unexpectedly returned no stream for a message send.',
+ );
+
+ expect(streamSpy).not.toHaveBeenCalled();
+
+ sendSpy.mockRestore();
+ streamSpy.mockRestore();
+ });
+
+ it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is set', async () => {
+ vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', '/tmp/test.jsonl');
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test',
+ prompt_id: 'prompt-id-activity-logger',
+ });
+
+ expect(mockSetupInitialActivityLogger).toHaveBeenCalledWith(mockConfig);
+ vi.unstubAllEnvs();
+ });
+
+ it('should not register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is not set', async () => {
+ vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', '');
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test',
+ prompt_id: 'prompt-id-activity-logger-off',
+ });
+
+ expect(mockSetupInitialActivityLogger).not.toHaveBeenCalled();
+ vi.unstubAllEnvs();
+ });
+
+ it('should handle a single tool call and respond', async () => {
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'tool-1',
+ name: 'testTool',
+ args: { arg1: 'value1' },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-2',
+ },
+ };
+ const toolResponse: Part[] = [{ text: 'Tool response' }];
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Success,
+ request: {
+ callId: 'tool-1',
+ name: 'testTool',
+ args: { arg1: 'value1' },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-2',
+ },
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ responseParts: toolResponse,
+ callId: 'tool-1',
+ error: undefined,
+ errorType: undefined,
+ contentLength: undefined,
+ },
+ },
+ ]);
+
+ const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
+ const secondCallEvents: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Final answer' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
+ .mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Use a tool',
+ prompt_id: 'prompt-id-2',
+ });
+
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
+ expect(mockSchedulerSchedule).toHaveBeenCalledWith(
+ [expect.objectContaining({ name: 'testTool' })],
+ expect.any(AbortSignal),
+ );
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
+ 2,
+ [{ text: 'Tool response' }],
+ expect.any(AbortSignal),
+ 'prompt-id-2',
+ undefined,
+ undefined,
+ );
+ expect(getWrittenOutput()).toBe('Final answer\n');
+ });
+
+ it('should write a single newline between sequential text outputs from the model', async () => {
+ // This test simulates a multi-turn conversation to ensure that a single newline
+ // is printed between each block of text output from the model.
+
+ // 1. Define the tool requests that the model will ask the CLI to run.
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'mock-tool',
+ name: 'mockTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-multi',
+ },
+ };
+
+ // 2. Mock the execution of the tools. We just need them to succeed.
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Success,
+ request: toolCallEvent.value, // This is generic enough for both calls
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ responseParts: [],
+ callId: 'mock-tool',
+ },
+ },
+ ]);
+
+ // 3. Define the sequence of events streamed from the mock model.
+ // Turn 1: Model outputs text, then requests a tool call.
+ const modelTurn1: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Use mock tool' },
+ toolCallEvent,
+ ];
+ // Turn 2: Model outputs more text, then requests another tool call.
+ const modelTurn2: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Use mock tool again' },
+ toolCallEvent,
+ ];
+ // Turn 3: Model outputs a final answer.
+ const modelTurn3: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Finished.' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(createStreamFromEvents(modelTurn1))
+ .mockReturnValueOnce(createStreamFromEvents(modelTurn2))
+ .mockReturnValueOnce(createStreamFromEvents(modelTurn3));
+
+ // 4. Run the command.
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Use mock tool multiple times',
+ prompt_id: 'prompt-id-multi',
+ });
+
+ // 5. Verify the output.
+ // The rendered output should contain the text from each turn, separated by a
+ // single newline, with a final newline at the end.
+ expect(getWrittenOutput()).toMatchSnapshot();
+
+ // Also verify the tools were called as expected.
+ expect(mockSchedulerSchedule).toHaveBeenCalledTimes(2);
+ });
+
+ it('should handle error during tool execution and should send error back to the model', async () => {
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'tool-1',
+ name: 'errorTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-3',
+ },
+ };
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Error,
+ request: {
+ callId: 'tool-1',
+ name: 'errorTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-3',
+ },
+ tool: {} as AnyDeclarativeTool,
+ response: {
+ callId: 'tool-1',
+ error: new Error('Execution failed'),
+ errorType: ToolErrorType.EXECUTION_FAILED,
+ responseParts: [
+ {
+ functionResponse: {
+ name: 'errorTool',
+ response: {
+ output: 'Error: Execution failed',
+ },
+ },
+ },
+ ],
+ resultDisplay: 'Execution failed',
+ contentLength: undefined,
+ },
+ },
+ ]);
+ const finalResponse: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Content,
+ value: 'Sorry, let me try again.',
+ },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(createStreamFromEvents([toolCallEvent]))
+ .mockReturnValueOnce(createStreamFromEvents(finalResponse));
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Trigger tool error',
+ prompt_id: 'prompt-id-3',
+ });
+
+ expect(mockSchedulerSchedule).toHaveBeenCalled();
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Error executing tool errorTool: Execution failed',
+ );
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
+ 2,
+ [
+ {
+ functionResponse: {
+ name: 'errorTool',
+ response: {
+ output: 'Error: Execution failed',
+ },
+ },
+ },
+ ],
+ expect.any(AbortSignal),
+ 'prompt-id-3',
+ undefined,
+ undefined,
+ );
+ expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
+ });
+
+ it('should exit with error if sendMessageStream throws initially', async () => {
+ const apiError = new Error('API connection failed');
+ mockGeminiClient.sendMessageStream.mockImplementation(() => {
+ throw apiError;
+ });
+
+ await expect(
+ runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Initial fail',
+ prompt_id: 'prompt-id-4',
+ }),
+ ).rejects.toThrow('API connection failed');
+ });
+
+ it('should not exit if a tool is not found, and should send error back to model', async () => {
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'tool-1',
+ name: 'nonexistentTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-5',
+ },
+ };
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Error,
+ request: {
+ callId: 'tool-1',
+ name: 'nonexistentTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-5',
+ },
+ response: {
+ callId: 'tool-1',
+ error: new Error('Tool "nonexistentTool" not found in registry.'),
+ resultDisplay: 'Tool "nonexistentTool" not found in registry.',
+ responseParts: [],
+ errorType: undefined,
+ contentLength: undefined,
+ },
+ },
+ ]);
+ const finalResponse: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Content,
+ value: "Sorry, I can't find that tool.",
+ },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(createStreamFromEvents([toolCallEvent]))
+ .mockReturnValueOnce(createStreamFromEvents(finalResponse));
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Trigger tool not found',
+ prompt_id: 'prompt-id-5',
+ });
+
+ expect(mockSchedulerSchedule).toHaveBeenCalled();
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.',
+ );
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
+ expect(getWrittenOutput()).toBe("Sorry, I can't find that tool.\n");
+ });
+
+ it('should exit when max session turns are exceeded', async () => {
+ vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0);
+ await expect(
+ runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Trigger loop',
+ prompt_id: 'prompt-id-6',
+ }),
+ ).rejects.toThrow('Reached max session turns for this session');
+ });
+
+ it('should preprocess @include commands before sending to the model', async () => {
+ // 1. Mock the imported atCommandProcessor
+ const { handleAtCommand } = await import(
+ './ui/hooks/atCommandProcessor.js'
+ );
+ const mockHandleAtCommand = vi.mocked(handleAtCommand);
+
+ // 2. Define the raw input and the expected processed output
+ const rawInput = 'Summarize @file.txt';
+ const processedParts: Part[] = [
+ { text: 'Summarize @file.txt' },
+ { text: '\n--- Content from referenced files ---\n' },
+ { text: 'This is the content of the file.' },
+ { text: '\n--- End of content ---' },
+ ];
+
+ // 3. Setup the mock to return the processed parts
+ mockHandleAtCommand.mockResolvedValue({
+ processedQuery: processedParts,
+ });
+
+ // Mock a simple stream response from the Gemini client
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Summary complete.' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ // 4. Run the non-interactive mode with the raw input
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: rawInput,
+ prompt_id: 'prompt-id-7',
+ });
+
+ // 5. Assert that sendMessageStream was called with the PROCESSED parts, not the raw input
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
+ processedParts,
+ expect.any(AbortSignal),
+ 'prompt-id-7',
+ undefined,
+ rawInput,
+ );
+
+ // 6. Assert the final output is correct
+ expect(getWrittenOutput()).toBe('Summary complete.\n');
+ });
+
+ it('should process input and write JSON output with stats', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Hello World' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-1',
+ });
+
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
+ [{ text: 'Test input' }],
+ expect.any(AbortSignal),
+ 'prompt-id-1',
+ undefined,
+ 'Test input',
+ );
+ expect(processStdoutSpy).toHaveBeenCalledWith(
+ JSON.stringify(
+ {
+ session_id: 'test-session-id',
+ response: 'Hello World',
+ stats: MOCK_SESSION_METRICS,
+ },
+ null,
+ 2,
+ ),
+ );
+ });
+
+ it('should write JSON output with stats for tool-only commands (no text response)', async () => {
+ // Test the scenario where a command completes successfully with only tool calls
+ // but no text response - this would have caught the original bug
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'tool-1',
+ name: 'testTool',
+ args: { arg1: 'value1' },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-tool-only',
+ },
+ };
+ const toolResponse: Part[] = [{ text: 'Tool executed successfully' }];
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Success,
+ request: {
+ callId: 'tool-1',
+ name: 'testTool',
+ args: { arg1: 'value1' },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-tool-only',
+ },
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ responseParts: toolResponse,
+ callId: 'tool-1',
+ error: undefined,
+ errorType: undefined,
+ contentLength: undefined,
+ },
+ },
+ ]);
+
+ // First call returns only tool call, no content
+ const firstCallEvents: ServerGeminiStreamEvent[] = [
+ toolCallEvent,
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
+ },
+ ];
+
+ // Second call returns no content (tool-only completion)
+ const secondCallEvents: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } },
+ },
+ ];
+
+ // Third call handles the auto-nudge recovery (when no response is received after tools)
+ const thirdCallEvents: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
+ .mockReturnValueOnce(createStreamFromEvents(secondCallEvents))
+ .mockReturnValueOnce(createStreamFromEvents(thirdCallEvents));
+
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Execute tool only',
+ prompt_id: 'prompt-id-tool-only',
+ });
+
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(3);
+ expect(mockSchedulerSchedule).toHaveBeenCalledWith(
+ [expect.objectContaining({ name: 'testTool' })],
+ expect.any(AbortSignal),
+ );
+
+ // This should output JSON with empty response but include stats
+ expect(processStdoutSpy).toHaveBeenCalledWith(
+ JSON.stringify(
+ {
+ session_id: 'test-session-id',
+ response: '',
+ stats: MOCK_SESSION_METRICS,
+ },
+ null,
+ 2,
+ ),
+ );
+ });
+
+ it('should keep only the final post-tool assistant text in JSON output', async () => {
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'tool-1',
+ name: 'testTool',
+ args: { arg1: 'value1' },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-json-tool-text',
+ },
+ };
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Success,
+ request: toolCallEvent.value,
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ responseParts: [{ text: 'Tool executed successfully' }],
+ callId: 'tool-1',
+ error: undefined,
+ errorType: undefined,
+ contentLength: undefined,
+ },
+ },
+ ]);
+
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(
+ createStreamFromEvents([
+ { type: GeminiEventType.Content, value: 'Let me check that...' },
+ toolCallEvent,
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
+ },
+ ]),
+ )
+ .mockReturnValueOnce(
+ createStreamFromEvents([
+ { type: GeminiEventType.Content, value: 'Final answer' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } },
+ },
+ ]),
+ );
+
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Use a tool',
+ prompt_id: 'prompt-id-json-tool-text',
+ });
+
+ expect(processStdoutSpy).toHaveBeenCalledWith(
+ JSON.stringify(
+ {
+ session_id: 'test-session-id',
+ response: 'Final answer',
+ stats: MOCK_SESSION_METRICS,
+ },
+ null,
+ 2,
+ ),
+ );
+ });
+
+ it('should write JSON output with stats for empty response commands', async () => {
+ // Test the scenario where a command completes but produces no content at all
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Empty response test',
+ prompt_id: 'prompt-id-empty',
+ });
+
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
+ [{ text: 'Empty response test' }],
+ expect.any(AbortSignal),
+ 'prompt-id-empty',
+ undefined,
+ 'Empty response test',
+ );
+
+ // This should output JSON with empty response but include stats
+ expect(processStdoutSpy).toHaveBeenCalledWith(
+ JSON.stringify(
+ {
+ session_id: 'test-session-id',
+ response: '',
+ stats: MOCK_SESSION_METRICS,
+ },
+ null,
+ 2,
+ ),
+ );
+ });
+
+ it('should handle errors in JSON format', async () => {
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+ const testError = new Error('Invalid input provided');
+
+ mockGeminiClient.sendMessageStream.mockImplementation(() => {
+ throw testError;
+ });
+
+ let thrownError: Error | null = null;
+ try {
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-error',
+ });
+ // Should not reach here
+ expect.fail('Expected process.exit to be called');
+ } catch (error) {
+ thrownError = error as Error;
+ }
+
+ // Should throw because of mocked process.exit
+ expect(thrownError?.message).toBe('process.exit(1) called');
+
+ expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
+ 'error',
+ JSON.stringify(
+ {
+ session_id: 'test-session-id',
+ error: {
+ type: 'Error',
+ message: 'Invalid input provided',
+ code: 1,
+ },
+ },
+ null,
+ 2,
+ ),
+ );
+ });
+
+ it('should handle FatalInputError with custom exit code in JSON format', async () => {
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+ const fatalError = new FatalInputError('Invalid command syntax provided');
+
+ mockGeminiClient.sendMessageStream.mockImplementation(() => {
+ throw fatalError;
+ });
+
+ let thrownError: Error | null = null;
+ try {
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Invalid syntax',
+ prompt_id: 'prompt-id-fatal',
+ });
+ // Should not reach here
+ expect.fail('Expected process.exit to be called');
+ } catch (error) {
+ thrownError = error as Error;
+ }
+
+ // Should throw because of mocked process.exit with custom exit code
+ expect(thrownError?.message).toBe('process.exit(42) called');
+
+ expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
+ 'error',
+ JSON.stringify(
+ {
+ session_id: 'test-session-id',
+ error: {
+ type: 'FatalInputError',
+ message: 'Invalid command syntax provided',
+ code: 42,
+ },
+ },
+ null,
+ 2,
+ ),
+ );
+ });
+
+ it('should execute a slash command that returns a prompt', async () => {
+ const mockCommand = {
+ name: 'testcommand',
+ description: 'a test command',
+ action: vi.fn().mockResolvedValue({
+ type: 'submit_prompt',
+ content: [{ text: 'Prompt from command' }],
+ }),
+ };
+ mockGetCommands.mockReturnValue([mockCommand]);
+
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Response from command' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: '/testcommand',
+ prompt_id: 'prompt-id-slash',
+ });
+
+ // Ensure the prompt sent to the model is from the command, not the raw input
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
+ [{ text: 'Prompt from command' }],
+ expect.any(AbortSignal),
+ 'prompt-id-slash',
+ undefined,
+ '/testcommand',
+ );
+
+ expect(getWrittenOutput()).toBe('Response from command\n');
+ });
+
+ it('should handle slash commands', async () => {
+ const nonInteractiveCliCommands = await import(
+ './nonInteractiveCliCommands.js'
+ );
+ const handleSlashCommandSpy = vi.spyOn(
+ nonInteractiveCliCommands,
+ 'handleSlashCommand',
+ );
+ handleSlashCommandSpy.mockResolvedValue([{ text: 'Slash command output' }]);
+
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Response to slash command' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: '/help',
+ prompt_id: 'prompt-id-slash',
+ });
+
+ expect(handleSlashCommandSpy).toHaveBeenCalledWith(
+ '/help',
+ expect.any(AbortController),
+ mockConfig,
+ mockSettings,
+ );
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
+ [{ text: 'Slash command output' }],
+ expect.any(AbortSignal),
+ 'prompt-id-slash',
+ undefined,
+ '/help',
+ );
+ expect(getWrittenOutput()).toBe('Response to slash command\n');
+ handleSlashCommandSpy.mockRestore();
+ });
+
+ it('should handle cancellation (Ctrl+C)', async () => {
+ // Mock isTTY and setRawMode safely
+ const originalIsTTY = process.stdin.isTTY;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const originalSetRawMode = (process.stdin as any).setRawMode;
+
+ Object.defineProperty(process.stdin, 'isTTY', {
+ value: true,
+ configurable: true,
+ });
+ if (!originalSetRawMode) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (process.stdin as any).setRawMode = vi.fn();
+ }
+
+ const stdinOnSpy = vi
+ .spyOn(process.stdin, 'on')
+ .mockImplementation(() => process.stdin);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ vi.spyOn(process.stdin as any, 'setRawMode').mockImplementation(() => true);
+ vi.spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin);
+ vi.spyOn(process.stdin, 'pause').mockImplementation(() => process.stdin);
+ vi.spyOn(process.stdin, 'removeAllListeners').mockImplementation(
+ () => process.stdin,
+ );
+
+ // Cancellation will throw FatalCancellationError directly
+
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Thinking...' },
+ ];
+ // Create a stream that responds to abortion
+ mockGeminiClient.sendMessageStream.mockImplementation(
+ (_messages, signal: AbortSignal) =>
+ (async function* () {
+ yield events[0];
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(resolve, 1000);
+ signal.addEventListener('abort', () => {
+ clearTimeout(timeout);
+ setTimeout(() => {
+ reject(new Error('Aborted'));
+ }, 300);
+ });
+ });
+ })(),
+ );
+
+ const runPromise = runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Long running query',
+ prompt_id: 'prompt-id-cancel',
+ });
+
+ // Wait a bit for setup to complete and listeners to be registered
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ // Find the keypress handler registered by runNonInteractive
+ const keypressCall = stdinOnSpy.mock.calls.find(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (call) => (call[0] as any) === 'keypress',
+ );
+ expect(keypressCall).toBeDefined();
+ const keypressHandler = keypressCall?.[1] as (
+ str: string,
+ key: { name?: string; ctrl?: boolean },
+ ) => void;
+
+ if (keypressHandler) {
+ // Simulate Ctrl+C
+ keypressHandler('\u0003', { ctrl: true, name: 'c' });
+ }
+
+ await expect(runPromise).rejects.toThrow('Operation cancelled.');
+
+ expect(
+ processStderrSpy.mock.calls.some(
+ // eslint-disable-next-line no-restricted-syntax
+ (call) => typeof call[0] === 'string' && call[0].includes('Cancelling'),
+ ),
+ ).toBe(true);
+
+ // Restore original values
+ Object.defineProperty(process.stdin, 'isTTY', {
+ value: originalIsTTY,
+ configurable: true,
+ });
+ if (originalSetRawMode) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (process.stdin as any).setRawMode = originalSetRawMode;
+ } else {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ delete (process.stdin as any).setRawMode;
+ }
+ // Spies are automatically restored by vi.restoreAllMocks() in afterEach,
+ // but we can also do it manually if needed.
+ });
+
+ it('should honor cancellation that happens before session.send()', async () => {
+ const originalIsTTY = process.stdin.isTTY;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const originalSetRawMode = (process.stdin as any).setRawMode;
+
+ Object.defineProperty(process.stdin, 'isTTY', {
+ value: true,
+ configurable: true,
+ });
+ if (!originalSetRawMode) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (process.stdin as any).setRawMode = vi.fn();
+ }
+
+ const stdinOnSpy = vi
+ .spyOn(process.stdin, 'on')
+ .mockImplementation(
+ (event: string | symbol, listener: (...args: unknown[]) => void) => {
+ if (event === 'keypress') {
+ listener('\u0003', { ctrl: true, name: 'c' });
+ }
+ return process.stdin;
+ },
+ );
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ vi.spyOn(process.stdin as any, 'setRawMode').mockImplementation(() => true);
+ vi.spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin);
+ vi.spyOn(process.stdin, 'pause').mockImplementation(() => process.stdin);
+ vi.spyOn(process.stdin, 'removeAllListeners').mockImplementation(
+ () => process.stdin,
+ );
+
+ // Cancellation will throw FatalCancellationError directly
+
+ const { LegacyAgentSession } = await import('@google/gemini-cli-core');
+ const sendSpy = vi.spyOn(LegacyAgentSession.prototype, 'send');
+
+ await expect(
+ runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Cancelled query',
+ prompt_id: 'prompt-id-pre-send-cancel',
+ }),
+ ).rejects.toThrow('Operation cancelled.');
+
+ expect(sendSpy).not.toHaveBeenCalled();
+ expect(stdinOnSpy).toHaveBeenCalled();
+ sendSpy.mockRestore();
+
+ Object.defineProperty(process.stdin, 'isTTY', {
+ value: originalIsTTY,
+ configurable: true,
+ });
+ if (originalSetRawMode) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (process.stdin as any).setRawMode = originalSetRawMode;
+ } else {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ delete (process.stdin as any).setRawMode;
+ }
+ });
+
+ it('should throw FatalInputError if a command requires confirmation', async () => {
+ const mockCommand = {
+ name: 'confirm',
+ description: 'a command that needs confirmation',
+ action: vi.fn().mockResolvedValue({
+ type: 'confirm_shell_commands',
+ commands: ['rm -rf /'],
+ }),
+ };
+ mockGetCommands.mockReturnValue([mockCommand]);
+
+ await expect(
+ runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: '/confirm',
+ prompt_id: 'prompt-id-confirm',
+ }),
+ ).rejects.toThrow(
+ 'Exiting due to a confirmation prompt requested by the command.',
+ );
+ });
+
+ it('should treat an unknown slash command as a regular prompt', async () => {
+ // No commands are mocked, so any slash command is "unknown"
+ mockGetCommands.mockReturnValue([]);
+
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Response to unknown' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: '/unknowncommand',
+ prompt_id: 'prompt-id-unknown',
+ });
+
+ // Ensure the raw input is sent to the model
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
+ [{ text: '/unknowncommand' }],
+ expect.any(AbortSignal),
+ 'prompt-id-unknown',
+ undefined,
+ '/unknowncommand',
+ );
+
+ expect(getWrittenOutput()).toBe('Response to unknown\n');
+ });
+
+ it('should throw for unhandled command result types', async () => {
+ const mockCommand = {
+ name: 'noaction',
+ description: 'unhandled type',
+ action: vi.fn().mockResolvedValue({
+ type: 'unhandled',
+ }),
+ };
+ mockGetCommands.mockReturnValue([mockCommand]);
+
+ await expect(
+ runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: '/noaction',
+ prompt_id: 'prompt-id-unhandled',
+ }),
+ ).rejects.toThrow(
+ 'Exiting due to command result that is not supported in non-interactive mode.',
+ );
+ });
+
+ it('should pass arguments to the slash command action', async () => {
+ const mockAction = vi.fn().mockResolvedValue({
+ type: 'submit_prompt',
+ content: [{ text: 'Prompt from command' }],
+ });
+ const mockCommand = {
+ name: 'testargs',
+ description: 'a test command',
+ action: mockAction,
+ };
+ mockGetCommands.mockReturnValue([mockCommand]);
+
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Acknowledged' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: '/testargs arg1 arg2',
+ prompt_id: 'prompt-id-args',
+ });
+
+ expect(mockAction).toHaveBeenCalledWith(expect.any(Object), 'arg1 arg2');
+
+ expect(getWrittenOutput()).toBe('Acknowledged\n');
+ });
+
+ it('should instantiate CommandService with correct loaders for slash commands', async () => {
+ // This test indirectly checks that handleSlashCommand is using the right loaders.
+ const { FileCommandLoader } = await import(
+ './services/FileCommandLoader.js'
+ );
+ const { McpPromptLoader } = await import('./services/McpPromptLoader.js');
+ const { BuiltinCommandLoader } = await import(
+ './services/BuiltinCommandLoader.js'
+ );
+ mockGetCommands.mockReturnValue([]); // No commands found, so it will fall through
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Acknowledged' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: '/mycommand',
+ prompt_id: 'prompt-id-loaders',
+ });
+
+ // Check that loaders were instantiated with the config
+ expect(FileCommandLoader).toHaveBeenCalledTimes(1);
+ expect(FileCommandLoader).toHaveBeenCalledWith(mockConfig);
+ expect(McpPromptLoader).toHaveBeenCalledTimes(1);
+ expect(McpPromptLoader).toHaveBeenCalledWith(mockConfig);
+ expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig);
+
+ // Check that instances were passed to CommandService.create
+ expect(mockCommandServiceCreate).toHaveBeenCalledTimes(1);
+ const loadersArg = mockCommandServiceCreate.mock.calls[0][0];
+ expect(loadersArg).toHaveLength(3);
+ expect(loadersArg[0]).toBe(
+ vi.mocked(BuiltinCommandLoader).mock.instances[0],
+ );
+ expect(loadersArg[1]).toBe(vi.mocked(McpPromptLoader).mock.instances[0]);
+ expect(loadersArg[2]).toBe(vi.mocked(FileCommandLoader).mock.instances[0]);
+ });
+
+ it('should allow a normally-excluded tool when --allowed-tools is set', async () => {
+ // By default, ShellTool is excluded in non-interactive mode.
+ // This test ensures that --allowed-tools overrides this exclusion.
+ vi.mocked(mockConfig.getToolRegistry).mockReturnValue({
+ getTool: vi.fn().mockReturnValue({
+ name: 'ShellTool',
+ description: 'A shell tool',
+ run: vi.fn(),
+ }),
+ getFunctionDeclarations: vi.fn().mockReturnValue([{ name: 'ShellTool' }]),
+ } as unknown as ToolRegistry);
+
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'tool-shell-1',
+ name: 'ShellTool',
+ args: { command: 'ls' },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-allowed',
+ },
+ };
+ const toolResponse: Part[] = [{ text: 'file.txt' }];
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Success,
+ request: {
+ callId: 'tool-shell-1',
+ name: 'ShellTool',
+ args: { command: 'ls' },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-allowed',
+ },
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ responseParts: toolResponse,
+ callId: 'tool-shell-1',
+ error: undefined,
+ errorType: undefined,
+ contentLength: undefined,
+ },
+ },
+ ]);
+
+ const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
+ const secondCallEvents: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'file.txt' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
+ .mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'List the files',
+ prompt_id: 'prompt-id-allowed',
+ });
+
+ expect(mockSchedulerSchedule).toHaveBeenCalledWith(
+ [expect.objectContaining({ name: 'ShellTool' })],
+ expect.any(AbortSignal),
+ );
+ expect(getWrittenOutput()).toBe('file.txt\n');
+ });
+
+ describe('CoreEvents Integration', () => {
+ it('subscribes to UserFeedback and drains backlog on start', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test',
+ prompt_id: 'prompt-id-events',
+ });
+
+ expect(mockCoreEvents.on).toHaveBeenCalledWith(
+ CoreEvent.UserFeedback,
+ expect.any(Function),
+ );
+ expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1);
+ });
+
+ it('unsubscribes from UserFeedback on finish', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test',
+ prompt_id: 'prompt-id-events',
+ });
+
+ expect(mockCoreEvents.off).toHaveBeenCalledWith(
+ CoreEvent.UserFeedback,
+ expect.any(Function),
+ );
+ });
+
+ it('logs to process.stderr when UserFeedback event is received', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test',
+ prompt_id: 'prompt-id-events',
+ });
+
+ // Get the registered handler
+ const handler = mockCoreEvents.on.mock.calls.find(
+ (call: unknown[]) => call[0] === CoreEvent.UserFeedback,
+ )?.[1];
+ expect(handler).toBeDefined();
+
+ // Simulate an event
+ const payload: UserFeedbackPayload = {
+ severity: 'error',
+ message: 'Test error message',
+ };
+ handler(payload);
+
+ expect(processStderrSpy).toHaveBeenCalledWith(
+ '[ERROR] Test error message\n',
+ );
+ });
+
+ it('logs optional error object to process.stderr in debug mode', async () => {
+ vi.mocked(mockConfig.getDebugMode).mockReturnValue(true);
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test',
+ prompt_id: 'prompt-id-events',
+ });
+
+ // Get the registered handler
+ const handler = mockCoreEvents.on.mock.calls.find(
+ (call: unknown[]) => call[0] === CoreEvent.UserFeedback,
+ )?.[1];
+ expect(handler).toBeDefined();
+
+ // Simulate an event with error object
+ const errorObj = new Error('Original error');
+ // Mock stack for deterministic testing
+ errorObj.stack = 'Error: Original error\n at test';
+ const payload: UserFeedbackPayload = {
+ severity: 'warning',
+ message: 'Test warning message',
+ error: errorObj,
+ };
+ handler(payload);
+
+ expect(processStderrSpy).toHaveBeenCalledWith(
+ '[WARNING] Test warning message\n',
+ );
+ expect(processStderrSpy).toHaveBeenCalledWith(
+ 'Error: Original error\n at test\n',
+ );
+ });
+ });
+
+ it('should emit appropriate events for streaming JSON output', async () => {
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
+ OutputFormat.STREAM_JSON,
+ );
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'tool-1',
+ name: 'testTool',
+ args: { arg1: 'value1' },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-stream',
+ },
+ };
+
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Success,
+ request: toolCallEvent.value,
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ responseParts: [{ text: 'Tool response' }],
+ callId: 'tool-1',
+ error: undefined,
+ errorType: undefined,
+ contentLength: undefined,
+ resultDisplay: 'Tool executed successfully',
+ },
+ },
+ ]);
+
+ const firstCallEvents: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Thinking...' },
+ toolCallEvent,
+ ];
+ const secondCallEvents: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Final answer' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
+ .mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Stream test',
+ prompt_id: 'prompt-id-stream',
+ });
+
+ const output = getWrittenOutput();
+ const sanitizedOutput = output
+ .replace(/"timestamp":"[^"]+"/g, '"timestamp":""')
+ .replace(/"duration_ms":\d+/g, '"duration_ms":');
+ expect(sanitizedOutput).toMatchSnapshot();
+ });
+
+ it('should handle EPIPE error gracefully', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Hello' },
+ { type: GeminiEventType.Content, value: ' World' },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ // Mock process.exit to track calls without throwing
+ vi.spyOn(process, 'exit').mockImplementation((_code) => undefined as never);
+
+ // Simulate EPIPE error on stdout
+ const stdoutErrorCallback = (process.stdout.on as Mock).mock.calls.find(
+ (call) => call[0] === 'error',
+ )?.[1];
+
+ if (stdoutErrorCallback) {
+ stdoutErrorCallback({ code: 'EPIPE' });
+ }
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'EPIPE test',
+ prompt_id: 'prompt-id-epipe',
+ });
+
+ // Since EPIPE is simulated, it might exit early or continue depending on timing,
+ // but our main goal is to verify the handler is registered and handles EPIPE.
+ expect(process.stdout.on).toHaveBeenCalledWith(
+ 'error',
+ expect.any(Function),
+ );
+ });
+
+ it('should resume chat when resumedSessionData is provided', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Resumed' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ const resumedSessionData = {
+ conversation: {
+ sessionId: 'resumed-session-id',
+ messages: [
+ { role: 'user', parts: [{ text: 'Previous message' }] },
+ ] as any, // eslint-disable-line @typescript-eslint/no-explicit-any
+ startTime: new Date().toISOString(),
+ lastUpdated: new Date().toISOString(),
+ firstUserMessage: 'Previous message',
+ projectHash: 'test-hash',
+ },
+ filePath: '/path/to/session.json',
+ };
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Continue',
+ prompt_id: 'prompt-id-resume',
+ resumedSessionData,
+ });
+
+ expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith(
+ expect.any(Array),
+ resumedSessionData,
+ );
+ expect(getWrittenOutput()).toBe('Resumed\n');
+ });
+
+ it.each([
+ {
+ name: 'loop detected',
+ events: [
+ { type: GeminiEventType.LoopDetected },
+ ] as ServerGeminiStreamEvent[],
+ input: 'Loop test',
+ promptId: 'prompt-id-loop',
+ },
+ {
+ name: 'max session turns',
+ events: [
+ { type: GeminiEventType.MaxSessionTurns },
+ ] as ServerGeminiStreamEvent[],
+ input: 'Max turns test',
+ promptId: 'prompt-id-max-turns',
+ },
+ ])(
+ 'should emit appropriate error event in streaming JSON mode: $name',
+ async ({ events, input, promptId }) => {
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
+ OutputFormat.STREAM_JSON,
+ );
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ const streamEvents: ServerGeminiStreamEvent[] = [
+ ...events,
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(streamEvents),
+ );
+
+ try {
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input,
+ prompt_id: promptId,
+ });
+ } catch {
+ // Expected exit
+ }
+
+ const output = getWrittenOutput();
+ const sanitizedOutput = output
+ .replace(/"timestamp":"[^"]+"/g, '"timestamp":""')
+ .replace(/"duration_ms":\d+/g, '"duration_ms":');
+ expect(sanitizedOutput).toMatchSnapshot();
+ },
+ );
+
+ it.each([
+ {
+ name: 'loop detected',
+ events: [
+ { type: GeminiEventType.LoopDetected },
+ ] as ServerGeminiStreamEvent[],
+ expectedWarning: 'Loop detected, stopping execution',
+ },
+ ])(
+ 'should include warning in JSON mode for: $name',
+ async ({ events, expectedWarning }) => {
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ const streamEvents: ServerGeminiStreamEvent[] = [
+ ...events,
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(streamEvents),
+ );
+
+ try {
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test',
+ prompt_id: 'test',
+ });
+ } catch {
+ // Expected exit for max turns
+ }
+
+ const output = JSON.parse(getWrittenOutput());
+ expect(output.warnings).toBeDefined();
+ expect(output.warnings[0]).toContain(expectedWarning);
+ },
+ );
+
+ it('should log error when tool recording fails', async () => {
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'tool-1',
+ name: 'testTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-tool-error',
+ },
+ };
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Success,
+ request: toolCallEvent.value,
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ responseParts: [],
+ callId: 'tool-1',
+ error: undefined,
+ errorType: undefined,
+ contentLength: undefined,
+ },
+ },
+ ]);
+
+ const events: ServerGeminiStreamEvent[] = [
+ toolCallEvent,
+ { type: GeminiEventType.Content, value: 'Done' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(createStreamFromEvents(events))
+ .mockReturnValueOnce(
+ createStreamFromEvents([
+ { type: GeminiEventType.Content, value: 'Done' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
+ },
+ ]),
+ );
+
+ // Mock getChat to throw when recording tool calls
+ const mockChat = {
+ recordCompletedToolCalls: vi.fn().mockImplementation(() => {
+ throw new Error('Recording failed');
+ }),
+ };
+ mockGeminiClient.getChat = vi.fn().mockReturnValue(mockChat);
+ mockGeminiClient.getCurrentSequenceModel = vi
+ .fn()
+ .mockReturnValue('model-1');
+
+ // Mock debugLogger.error
+ const { debugLogger } = await import('@google/gemini-cli-core');
+ const debugLoggerErrorSpy = vi
+ .spyOn(debugLogger, 'error')
+ .mockImplementation(() => {});
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Tool recording error test',
+ prompt_id: 'prompt-id-tool-error',
+ });
+
+ expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'Error recording completed tool call information: Error: Recording failed',
+ ),
+ );
+ expect(getWrittenOutput()).toContain('Done');
+ });
+
+ it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => {
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'stop-call',
+ name: 'stopTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-stop',
+ },
+ };
+
+ // Mock tool execution returning STOP_EXECUTION
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Error,
+ request: toolCallEvent.value,
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ callId: 'stop-call',
+ responseParts: [{ text: 'error occurred' }],
+ errorType: ToolErrorType.STOP_EXECUTION,
+ error: new Error('Stop reason from hook'),
+ resultDisplay: undefined,
+ },
+ },
+ ]);
+
+ const firstCallEvents: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Executing tool...' },
+ toolCallEvent,
+ ];
+
+ // Setup the mock to return events for the first call.
+ // We expect the loop to terminate after the tool execution.
+ // If it doesn't, it might call sendMessageStream again, which we'll assert against.
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
+ .mockReturnValueOnce(createStreamFromEvents([]));
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Run stop tool',
+ prompt_id: 'prompt-id-stop',
+ });
+
+ expect(mockSchedulerSchedule).toHaveBeenCalled();
+
+ // The key assertion: sendMessageStream should have been called ONLY ONCE (initial user input).
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
+
+ expect(processStderrSpy).toHaveBeenCalledWith(
+ 'Agent execution stopped: Stop reason from hook\n',
+ );
+ });
+
+ it('should write JSON output when a tool call returns STOP_EXECUTION error', async () => {
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'stop-call',
+ name: 'stopTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-stop-json',
+ },
+ };
+
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Error,
+ request: toolCallEvent.value,
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ callId: 'stop-call',
+ responseParts: [{ text: 'error occurred' }],
+ errorType: ToolErrorType.STOP_EXECUTION,
+ error: new Error('Stop reason'),
+ resultDisplay: undefined,
+ },
+ },
+ ]);
+
+ const firstCallEvents: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Partial content' },
+ toolCallEvent,
+ ];
+
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(firstCallEvents),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Run stop tool',
+ prompt_id: 'prompt-id-stop-json',
+ });
+
+ expect(processStdoutSpy).toHaveBeenCalledWith(
+ JSON.stringify(
+ {
+ session_id: 'test-session-id',
+ response: 'Partial content',
+ stats: MOCK_SESSION_METRICS,
+ },
+ null,
+ 2,
+ ),
+ );
+ });
+
+ it('should emit result event when a tool call returns STOP_EXECUTION error in streaming JSON mode', async () => {
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
+ OutputFormat.STREAM_JSON,
+ );
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'stop-call',
+ name: 'stopTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-stop-stream',
+ },
+ };
+
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Error,
+ request: toolCallEvent.value,
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ callId: 'stop-call',
+ responseParts: [{ text: 'error occurred' }],
+ errorType: ToolErrorType.STOP_EXECUTION,
+ error: new Error('Stop reason'),
+ resultDisplay: undefined,
+ },
+ },
+ ]);
+
+ const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
+
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(firstCallEvents),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Run stop tool',
+ prompt_id: 'prompt-id-stop-stream',
+ });
+
+ const output = getWrittenOutput();
+ expect(output).toContain('"type":"result"');
+ expect(output).toContain('"status":"success"');
+ });
+
+ describe('Agent Execution Events', () => {
+ it('should handle AgentExecutionStopped event', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.AgentExecutionStopped,
+ value: { reason: 'Stopped by hook' },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test stop',
+ prompt_id: 'prompt-id-stop',
+ });
+
+ expect(processStderrSpy).toHaveBeenCalledWith(
+ 'Agent execution stopped: Stopped by hook\n',
+ );
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
+ });
+
+ it('should write JSON output when AgentExecutionStopped event occurs', async () => {
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Partial content' },
+ {
+ type: GeminiEventType.AgentExecutionStopped,
+ value: { reason: 'Stopped by hook' },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test stop',
+ prompt_id: 'prompt-id-stop-json',
+ });
+
+ expect(processStdoutSpy).toHaveBeenCalledWith(
+ JSON.stringify(
+ {
+ session_id: 'test-session-id',
+ response: 'Partial content',
+ stats: MOCK_SESSION_METRICS,
+ },
+ null,
+ 2,
+ ),
+ );
+ });
+
+ it('should emit result event when AgentExecutionStopped event occurs in streaming JSON mode', async () => {
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
+ OutputFormat.STREAM_JSON,
+ );
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Partial content' },
+ {
+ type: GeminiEventType.AgentExecutionStopped,
+ value: { reason: 'Stopped by hook' },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test stop',
+ prompt_id: 'prompt-id-stop-stream',
+ });
+
+ const output = getWrittenOutput();
+ expect(output).toContain('"type":"result"');
+ expect(output).toContain('"status":"success"');
+ });
+
+ it('should handle AgentExecutionBlocked event', async () => {
+ const allEvents: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.AgentExecutionBlocked,
+ value: { reason: 'Blocked by hook' },
+ },
+ { type: GeminiEventType.Content, value: 'Final answer' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(allEvents),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test block',
+ prompt_id: 'prompt-id-block',
+ });
+
+ expect(processStderrSpy).toHaveBeenCalledWith(
+ '[WARNING] Agent execution blocked: Blocked by hook\n',
+ );
+ // Stream continues after blocked event — content should be output
+ expect(getWrittenOutput()).toBe('Final answer\n');
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
+ });
+
+ it('should emit ERROR event in STREAM_JSON mode when AgentExecutionBlocked occurs', async () => {
+ const allEvents: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.AgentExecutionBlocked,
+ value: { reason: 'Blocked by hook' },
+ },
+ { type: GeminiEventType.Content, value: 'Final answer' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(allEvents),
+ );
+
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ // Setup stream-json format
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
+ OutputFormat.STREAM_JSON,
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test block',
+ prompt_id: 'prompt-id-block',
+ });
+
+ const calls = processStdoutSpy.mock.calls.map((call) =>
+ JSON.parse(call[0] as string),
+ );
+ const errorEvent = calls.find(
+ (c) => c.type === JsonStreamEventType.ERROR,
+ );
+
+ expect(errorEvent).toBeDefined();
+ expect(errorEvent.message).toContain(
+ 'Agent execution blocked: Blocked by hook',
+ );
+ expect(errorEvent.severity).toBe('warning');
+ });
+
+ it('should include warning in JSON mode when AgentExecutionBlocked occurs', async () => {
+ const allEvents: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.AgentExecutionBlocked,
+ value: { reason: 'Blocked by hook' },
+ },
+ { type: GeminiEventType.Content, value: 'Final answer' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(allEvents),
+ );
+
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test block',
+ prompt_id: 'prompt-id-block',
+ });
+
+ const output = JSON.parse(getWrittenOutput());
+ expect(output.warnings).toBeDefined();
+ expect(output.warnings).toContain(
+ 'Agent execution blocked: Blocked by hook',
+ );
+ });
+
+ it('should handle multiple AgentExecutionBlocked events and collect all warnings', async () => {
+ const allEvents: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.AgentExecutionBlocked,
+ value: { reason: 'Block 1', systemMessage: 'Reason 1' },
+ },
+ {
+ type: GeminiEventType.AgentExecutionBlocked,
+ value: { reason: 'Block 2', systemMessage: 'Reason 2' },
+ },
+ { type: GeminiEventType.Content, value: 'Final answer' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream.mockImplementation(() =>
+ createStreamFromEvents(allEvents),
+ );
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test',
+ prompt_id: 'test',
+ });
+
+ const output = JSON.parse(getWrittenOutput());
+ expect(output.warnings).toHaveLength(2);
+ expect(output.warnings).toContain('Agent execution blocked: Reason 1');
+ expect(output.warnings).toContain('Agent execution blocked: Reason 2');
+ });
+
+ it('should not include warnings field in JSON output if no blocks occur', async () => {
+ const allEvents: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Clean answer' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream.mockImplementation(() =>
+ createStreamFromEvents(allEvents),
+ );
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test',
+ prompt_id: 'test',
+ });
+
+ const output = JSON.parse(getWrittenOutput());
+ expect(output.warnings).toBeUndefined();
+ });
+
+ it('should handle InvalidStream event gracefully in TEXT mode', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.InvalidStream,
+ value: {
+ type: 'NO_RESPONSE_TEXT',
+ message: 'Empty response',
+ },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test invalid stream',
+ prompt_id: 'prompt-id-invalid',
+ });
+
+ expect(processStderrSpy).toHaveBeenCalledWith(
+ `[ERROR] ${TRUE_EMPTY_RESPONSE_MESSAGE}\n`,
+ );
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle InvalidStream event gracefully in STREAM_JSON mode', async () => {
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+ vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue(
+ OutputFormat.STREAM_JSON,
+ );
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.InvalidStream,
+ value: {
+ type: 'NO_RESPONSE_TEXT',
+ message: 'Empty response',
+ },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test invalid stream',
+ prompt_id: 'prompt-id-invalid',
+ });
+
+ const output = getWrittenOutput();
+ expect(output).toContain('"type":"error"');
+ expect(output).toContain('"severity":"error"');
+ expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle InvalidStream event gracefully in JSON mode', async () => {
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+ vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue(
+ OutputFormat.JSON,
+ );
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.InvalidStream,
+ value: {
+ type: 'NO_RESPONSE_TEXT',
+ message: 'Empty response',
+ },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test invalid stream',
+ prompt_id: 'prompt-id-invalid',
+ });
+
+ const output = getWrittenOutput();
+ expect(output).toContain('"error": {');
+ expect(output).toContain('"type": "INVALID_STREAM"');
+ expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle non-NO_RESPONSE_TEXT InvalidStream event gracefully and use message from eventValue', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.InvalidStream,
+ value: {
+ type: 'MALFORMED_FUNCTION_CALL',
+ message: 'Malformed call',
+ },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'test invalid stream',
+ prompt_id: 'prompt-id-invalid',
+ });
+
+ expect(processStderrSpy).toHaveBeenCalledWith('[ERROR] Malformed call\n');
+ expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Output Sanitization', () => {
+ const ANSI_SEQUENCE = '\u001B[31mRed Text\u001B[0m';
+ const OSC_HYPERLINK =
+ '\u001B]8;;http://example.com\u001B\\Link\u001B]8;;\u001B\\';
+ const PLAIN_TEXT_RED = 'Red Text';
+ const PLAIN_TEXT_LINK = 'Link';
+
+ it('should sanitize ANSI output by default', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: ANSI_SEQUENCE },
+ { type: GeminiEventType.Content, value: ' ' },
+ { type: GeminiEventType.Content, value: OSC_HYPERLINK },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ vi.mocked(mockConfig.getRawOutput).mockReturnValue(false);
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-sanitization',
+ });
+
+ expect(getWrittenOutput()).toBe(`${PLAIN_TEXT_RED} ${PLAIN_TEXT_LINK}\n`);
+ });
+
+ it('should allow ANSI output when rawOutput is true', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: ANSI_SEQUENCE },
+ { type: GeminiEventType.Content, value: ' ' },
+ { type: GeminiEventType.Content, value: OSC_HYPERLINK },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ vi.mocked(mockConfig.getRawOutput).mockReturnValue(true);
+ vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true);
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-raw',
+ });
+
+ expect(getWrittenOutput()).toBe(`${ANSI_SEQUENCE} ${OSC_HYPERLINK}\n`);
+ });
+
+ it('should allow ANSI output when only acceptRawOutputRisk is true', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: ANSI_SEQUENCE },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ vi.mocked(mockConfig.getRawOutput).mockReturnValue(false);
+ vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true);
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-accept-only',
+ });
+
+ expect(getWrittenOutput()).toBe(`${ANSI_SEQUENCE}\n`);
+ });
+
+ it('should warn when rawOutput is true and acceptRisk is false', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ vi.mocked(mockConfig.getRawOutput).mockReturnValue(true);
+ vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(false);
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-warn',
+ });
+
+ expect(processStderrSpy).toHaveBeenCalledWith(
+ expect.stringContaining('[WARNING] --raw-output is enabled'),
+ );
+ });
+
+ it('should not warn when rawOutput is true and acceptRisk is true', async () => {
+ const events: ServerGeminiStreamEvent[] = [
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ vi.mocked(mockConfig.getRawOutput).mockReturnValue(true);
+ vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true);
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-no-warn',
+ });
+
+ expect(processStderrSpy).not.toHaveBeenCalledWith(
+ expect.stringContaining('[WARNING] --raw-output is enabled'),
+ );
+ });
+
+ it('should emit warning event for loop_detected in streaming JSON mode', async () => {
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
+ OutputFormat.STREAM_JSON,
+ );
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ const streamEvents: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.LoopDetected } as ServerGeminiStreamEvent,
+ { type: GeminiEventType.Content, value: 'Continuing after loop' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
+ },
+ ];
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(streamEvents),
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Loop test explicit',
+ prompt_id: 'prompt-id-loop-explicit',
+ });
+
+ const output = getWrittenOutput();
+ // The STREAM_JSON output should contain an error event with warning severity
+ expect(output).toContain('"type":"error"');
+ expect(output).toContain('"severity":"warning"');
+ expect(output).toContain('Loop detected');
+ });
+
+ it('should report cancelled tool calls as success in stream-json mode (legacy parity)', async () => {
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'tool-1',
+ name: 'testTool',
+ args: { arg1: 'value1' },
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-cancel',
+ },
+ };
+
+ // Mock the scheduler to return a cancelled status
+ mockSchedulerSchedule.mockResolvedValue([
+ {
+ status: CoreToolCallStatus.Cancelled,
+ request: toolCallEvent.value,
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ callId: 'tool-1',
+ responseParts: [{ text: 'Operation cancelled' }],
+ resultDisplay: 'Cancelled',
+ },
+ },
+ ]);
+
+ const events: ServerGeminiStreamEvent[] = [
+ toolCallEvent,
+ {
+ type: GeminiEventType.Content,
+ value: 'Model continues...',
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream.mockReturnValue(
+ createStreamFromEvents(events),
+ );
+
+ vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
+ OutputFormat.STREAM_JSON,
+ );
+ vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
+ MOCK_SESSION_METRICS,
+ );
+
+ await runNonInteractive({
+ config: mockConfig,
+ settings: mockSettings,
+ input: 'Test input',
+ prompt_id: 'prompt-id-cancel',
+ });
+
+ const output = getWrittenOutput();
+ expect(output).toContain('"type":"tool_result"');
+ expect(output).toContain('"status":"success"');
+ });
+ });
+});
diff --git a/packages/cli/src/nonInteractiveCliAgentSession.ts b/packages/cli/src/nonInteractiveCliAgentSession.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f052e007010ebe563ba5a3d1852662328b62a87b
--- /dev/null
+++ b/packages/cli/src/nonInteractiveCliAgentSession.ts
@@ -0,0 +1,707 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {
+ Config,
+ ResumedSessionData,
+ UserFeedbackPayload,
+ AgentEvent,
+ ContentPart,
+} from '@google/gemini-cli-core';
+import { isSlashCommand } from './ui/utils/commandUtils.js';
+import type { LoadedSettings } from './config/settings.js';
+import {
+ convertSessionToClientHistory,
+ FatalError,
+ FatalAuthenticationError,
+ FatalInputError,
+ FatalSandboxError,
+ FatalConfigError,
+ FatalTurnLimitedError,
+ FatalToolExecutionError,
+ FatalCancellationError,
+ promptIdContext,
+ OutputFormat,
+ JsonFormatter,
+ StreamJsonFormatter,
+ JsonStreamEventType,
+ uiTelemetryService,
+ coreEvents,
+ CoreEvent,
+ createWorkingStdio,
+ Scheduler,
+ ROOT_SCHEDULER_ID,
+ LegacyAgentSession,
+ ToolErrorType,
+ geminiPartsToContentParts,
+ displayContentToString,
+ debugLogger,
+ THINKING_ONLY_COMPRESS_SUGGESTION,
+ MAX_TOKENS_EXCEEDED_SUGGESTION,
+ SAFETY_BLOCKED_MESSAGE,
+ RECITATION_BLOCKED_MESSAGE,
+ OTHER_BLOCKED_MESSAGE,
+ TRUE_EMPTY_RESPONSE_MESSAGE,
+} from '@google/gemini-cli-core';
+
+import type { Part } from '@google/genai';
+import readline from 'node:readline';
+import stripAnsi from 'strip-ansi';
+
+import { handleSlashCommand } from './nonInteractiveCliCommands.js';
+import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
+import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
+import { handleError, handleToolError } from './utils/errors.js';
+import { TextOutput } from './ui/utils/textOutput.js';
+
+interface RunNonInteractiveParams {
+ config: Config;
+ settings: LoadedSettings;
+ input: string;
+ prompt_id: string;
+ resumedSessionData?: ResumedSessionData;
+}
+
+/**
+ * Runs the non-interactive CLI using the LegacyAgentSession.
+ *
+ * Programmatic output formats (JSON, STREAM_JSON) use lenient sanitization
+ * by stripping ANSI escape sequences from messages to ensure clean,
+ * parseable output for downstream consumers.
+ */
+export async function runNonInteractive({
+ config,
+ settings,
+ input,
+ prompt_id,
+ resumedSessionData,
+}: RunNonInteractiveParams): Promise {
+ return promptIdContext.run(prompt_id, async () => {
+ const consolePatcher = new ConsolePatcher({
+ stderr: true,
+ interactive: false,
+ debugMode: config.getDebugMode(),
+ onNewMessage: (msg) => {
+ coreEvents.emitConsoleLog(msg.type, msg.content);
+ },
+ });
+
+ if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) {
+ const { setupInitialActivityLogger } = await import(
+ './utils/devtoolsService.js'
+ );
+ setupInitialActivityLogger(config);
+ }
+
+ const { stdout: workingStdout } = createWorkingStdio();
+ const textOutput = new TextOutput(workingStdout);
+
+ const handleUserFeedback = (payload: UserFeedbackPayload) => {
+ const prefix = payload.severity.toUpperCase();
+ process.stderr.write(`[${prefix}] ${payload.message}\n`);
+ if (payload.error && config.getDebugMode()) {
+ const errorToLog =
+ payload.error instanceof Error
+ ? payload.error.stack || payload.error.message
+ : String(payload.error);
+ process.stderr.write(`${errorToLog}\n`);
+ }
+ };
+
+ const startTime = Date.now();
+ const streamFormatter =
+ config.getOutputFormat() === OutputFormat.STREAM_JSON
+ ? new StreamJsonFormatter()
+ : null;
+
+ const abortController = new AbortController();
+
+ // Track cancellation state
+ let isAborting = false;
+ let cancelMessageTimer: NodeJS.Timeout | null = null;
+
+ // Setup stdin listener for Ctrl+C detection
+ let stdinWasRaw = false;
+ let rl: readline.Interface | null = null;
+
+ const setupStdinCancellation = () => {
+ // Only setup if stdin is a TTY (user can interact)
+ if (!process.stdin.isTTY) {
+ return;
+ }
+
+ // Save original raw mode state
+ stdinWasRaw = process.stdin.isRaw || false;
+
+ // Enable raw mode to capture individual keypresses
+ process.stdin.setRawMode(true);
+ process.stdin.resume();
+
+ // Setup readline to emit keypress events
+ rl = readline.createInterface({
+ input: process.stdin,
+ escapeCodeTimeout: 0,
+ });
+ readline.emitKeypressEvents(process.stdin, rl);
+
+ // Listen for Ctrl+C
+ const keypressHandler = (
+ str: string,
+ key: { name?: string; ctrl?: boolean },
+ ) => {
+ // Detect Ctrl+C: either ctrl+c key combo or raw character code 3
+ if ((key && key.ctrl && key.name === 'c') || str === '\u0003') {
+ // Only handle once
+ if (isAborting) {
+ return;
+ }
+
+ isAborting = true;
+
+ // Only show message if cancellation takes longer than 200ms
+ // This reduces verbosity for fast cancellations
+ cancelMessageTimer = setTimeout(() => {
+ process.stderr.write('\nCancelling...\n');
+ }, 200);
+
+ abortController.abort();
+ }
+ };
+
+ process.stdin.on('keypress', keypressHandler);
+ };
+
+ const cleanupStdinCancellation = () => {
+ // Clear any pending cancel message timer
+ if (cancelMessageTimer) {
+ clearTimeout(cancelMessageTimer);
+ cancelMessageTimer = null;
+ }
+
+ // Cleanup readline and stdin listeners
+ if (rl) {
+ rl.close();
+ rl = null;
+ }
+
+ // Remove keypress listener
+ process.stdin.removeAllListeners('keypress');
+
+ // Restore stdin to original state
+ if (process.stdin.isTTY) {
+ process.stdin.setRawMode(stdinWasRaw);
+ process.stdin.pause();
+ }
+ };
+
+ let errorToHandle: unknown | undefined;
+ let scheduler: Scheduler | undefined;
+ let abortSession = () => {};
+ try {
+ consolePatcher.patch();
+
+ if (
+ config.getRawOutput() &&
+ !config.getAcceptRawOutputRisk() &&
+ config.getOutputFormat() === OutputFormat.TEXT
+ ) {
+ process.stderr.write(
+ '[WARNING] --raw-output is enabled. Model output is not sanitized and may contain harmful ANSI sequences (e.g. for phishing or command injection). Use --accept-raw-output-risk to suppress this warning.\n',
+ );
+ }
+
+ // Setup stdin cancellation listener
+ setupStdinCancellation();
+
+ coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback);
+ coreEvents.drainBacklogs();
+
+ // Handle EPIPE errors when the output is piped to a command that closes early.
+ process.stdout.on('error', (err: NodeJS.ErrnoException) => {
+ if (err.code === 'EPIPE') {
+ // Exit gracefully if the pipe is closed.
+ cleanupStdinCancellation();
+ consolePatcher.cleanup();
+ process.exit(0);
+ }
+ });
+
+ const geminiClient = config.getGeminiClient();
+ scheduler = new Scheduler({
+ context: config,
+ messageBus: config.getMessageBus(),
+ getPreferredEditor: () => undefined,
+ schedulerId: ROOT_SCHEDULER_ID,
+ });
+
+ // Initialize chat. Resume if resume data is passed.
+ if (resumedSessionData) {
+ await geminiClient.resumeChat(
+ convertSessionToClientHistory(
+ resumedSessionData.conversation.messages,
+ ),
+ resumedSessionData,
+ );
+ }
+
+ // Emit init event for streaming JSON
+ if (streamFormatter) {
+ streamFormatter.emitEvent({
+ type: JsonStreamEventType.INIT,
+ timestamp: new Date().toISOString(),
+ session_id: config.getSessionId(),
+ model: config.getModel(),
+ });
+ }
+
+ let query: Part[] | undefined;
+
+ if (isSlashCommand(input)) {
+ const slashCommandResult = await handleSlashCommand(
+ input,
+ abortController,
+ config,
+ settings,
+ );
+ if (slashCommandResult) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ query = slashCommandResult as Part[];
+ }
+ }
+
+ if (!query) {
+ const { processedQuery, error } = await handleAtCommand({
+ query: input,
+ config,
+ addItem: (_item, _timestamp) => 0,
+ onDebugMessage: () => {},
+ messageId: Date.now(),
+ signal: abortController.signal,
+ escapePastedAtSymbols: false,
+ });
+ if (error || !processedQuery) {
+ throw new FatalInputError(
+ error || 'Exiting due to an error processing the @ command.',
+ );
+ }
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ query = processedQuery as Part[];
+ }
+
+ // Emit user message event for streaming JSON
+ if (streamFormatter) {
+ streamFormatter.emitEvent({
+ type: JsonStreamEventType.MESSAGE,
+ timestamp: new Date().toISOString(),
+ role: 'user',
+ content: input,
+ });
+ }
+
+ // Create LegacyAgentSession — owns the agentic loop
+ const session = new LegacyAgentSession({
+ client: geminiClient,
+ scheduler,
+ config,
+ promptId: prompt_id,
+ });
+
+ // Wire Ctrl+C to session abort
+ abortSession = () => {
+ void session.abort();
+ };
+ abortController.signal.addEventListener('abort', abortSession);
+ if (abortController.signal.aborted) {
+ throw new FatalCancellationError('Operation cancelled.');
+ }
+
+ // Start the agentic loop (runs in background)
+ const { streamId } = await session.send({
+ message: {
+ content: geminiPartsToContentParts(query),
+ displayContent: input,
+ },
+ });
+ if (streamId === null) {
+ throw new Error(
+ 'LegacyAgentSession.send() unexpectedly returned no stream for a message send.',
+ );
+ }
+
+ const getTextContent = (parts?: ContentPart[]): string | undefined => {
+ const text = parts
+ ?.map((part) => (part.type === 'text' ? part.text : ''))
+ .join('');
+ return text ? text : undefined;
+ };
+
+ const emitFinalResult = (errorPayload?: {
+ type: string;
+ message: string;
+ }): void => {
+ if (streamFormatter) {
+ const metrics = uiTelemetryService.getMetrics();
+ const durationMs = Date.now() - startTime;
+ streamFormatter.emitEvent({
+ type: JsonStreamEventType.RESULT,
+ timestamp: new Date().toISOString(),
+ status: errorPayload ? 'error' : 'success',
+ stats: streamFormatter.convertToStreamStats(metrics, durationMs),
+ });
+ } else if (config.getOutputFormat() === OutputFormat.JSON) {
+ const formatter = new JsonFormatter();
+ const stats = uiTelemetryService.getMetrics();
+ textOutput.write(
+ formatter.format(
+ config.getSessionId(),
+ responseText,
+ stats,
+ errorPayload,
+ warnings,
+ ),
+ );
+ } else {
+ textOutput.ensureTrailingNewline();
+ }
+ };
+
+ const reconstructFatalError = (event: AgentEvent<'error'>): Error => {
+ const errorMeta = event._meta;
+ const name =
+ typeof errorMeta?.['errorName'] === 'string'
+ ? errorMeta['errorName']
+ : undefined;
+
+ let errToThrow: Error;
+ switch (name) {
+ case 'FatalAuthenticationError':
+ errToThrow = new FatalAuthenticationError(event.message);
+ break;
+ case 'FatalInputError':
+ errToThrow = new FatalInputError(event.message);
+ break;
+ case 'FatalSandboxError':
+ errToThrow = new FatalSandboxError(event.message);
+ break;
+ case 'FatalConfigError':
+ errToThrow = new FatalConfigError(event.message);
+ break;
+ case 'FatalTurnLimitedError':
+ errToThrow = new FatalTurnLimitedError(event.message);
+ break;
+ case 'FatalToolExecutionError':
+ errToThrow = new FatalToolExecutionError(event.message);
+ break;
+ case 'FatalCancellationError':
+ errToThrow = new FatalCancellationError(event.message);
+ break;
+ case 'FatalError':
+ errToThrow = new FatalError(
+ event.message,
+ typeof errorMeta?.['exitCode'] === 'number'
+ ? errorMeta['exitCode']
+ : 1,
+ );
+ break;
+ default:
+ errToThrow = new Error(event.message);
+ if (name) {
+ Object.defineProperty(errToThrow, 'name', {
+ value: name,
+ enumerable: true,
+ });
+ }
+ break;
+ }
+
+ if (errorMeta?.['exitCode'] !== undefined) {
+ Object.defineProperty(errToThrow, 'exitCode', {
+ value: errorMeta['exitCode'],
+ enumerable: true,
+ });
+ }
+ if (errorMeta?.['code'] !== undefined) {
+ Object.defineProperty(errToThrow, 'code', {
+ value: errorMeta['code'],
+ enumerable: true,
+ });
+ }
+ if (errorMeta?.['status'] !== undefined) {
+ Object.defineProperty(errToThrow, 'status', {
+ value: errorMeta['status'],
+ enumerable: true,
+ });
+ }
+ return errToThrow;
+ };
+
+ // Consume AgentEvents for output formatting
+ let responseText = '';
+ let preToolResponseText: string | undefined;
+ let streamEnded = false;
+ const warnings: string[] = [];
+ for await (const event of session.stream({ streamId })) {
+ if (streamEnded) break;
+ switch (event.type) {
+ case 'message': {
+ if (event.role === 'agent') {
+ for (const part of event.content) {
+ if (part.type === 'text') {
+ const isRaw =
+ config.getRawOutput() || config.getAcceptRawOutputRisk();
+ const output = isRaw ? part.text : stripAnsi(part.text);
+ if (streamFormatter) {
+ streamFormatter.emitEvent({
+ type: JsonStreamEventType.MESSAGE,
+ timestamp: new Date().toISOString(),
+ role: 'assistant',
+ content: output,
+ delta: true,
+ });
+ } else if (config.getOutputFormat() === OutputFormat.JSON) {
+ responseText += output;
+ } else {
+ if (part.text) {
+ textOutput.write(output);
+ }
+ }
+ }
+ }
+ }
+ break;
+ }
+ case 'tool_request': {
+ if (config.getOutputFormat() === OutputFormat.JSON) {
+ // Final JSON output should reflect the last assistant answer after
+ // any tool orchestration, not intermediate pre-tool text.
+ preToolResponseText = responseText || preToolResponseText;
+ responseText = '';
+ }
+ if (streamFormatter) {
+ streamFormatter.emitEvent({
+ type: JsonStreamEventType.TOOL_USE,
+ timestamp: new Date().toISOString(),
+ tool_name: event.name,
+ tool_id: event.requestId,
+ parameters: event.args,
+ });
+ }
+ break;
+ }
+ case 'tool_response': {
+ textOutput.ensureTrailingNewline();
+ if (streamFormatter) {
+ const display = event.display?.result;
+ const displayText = displayContentToString(display);
+ const errorMsg = getTextContent(event.content) ?? 'Tool error';
+ streamFormatter.emitEvent({
+ type: JsonStreamEventType.TOOL_RESULT,
+ timestamp: new Date().toISOString(),
+ tool_id: event.requestId,
+ status: event.isError ? 'error' : 'success',
+ output: displayText,
+ error: event.isError
+ ? {
+ type:
+ typeof event.data?.['errorType'] === 'string'
+ ? event.data['errorType']
+ : 'TOOL_EXECUTION_ERROR',
+ message: errorMsg,
+ }
+ : undefined,
+ });
+ }
+ if (event.isError) {
+ const display = event.display?.result;
+ const displayText = displayContentToString(display);
+ const errorMsg = getTextContent(event.content) ?? 'Tool error';
+
+ if (event.data?.['errorType'] === ToolErrorType.STOP_EXECUTION) {
+ if (
+ config.getOutputFormat() === OutputFormat.JSON &&
+ !responseText &&
+ preToolResponseText
+ ) {
+ responseText = preToolResponseText;
+ }
+ const stopMessage = `Agent execution stopped: ${errorMsg}`;
+ if (config.getOutputFormat() === OutputFormat.TEXT) {
+ process.stderr.write(`${stopMessage}\n`);
+ }
+ }
+
+ if (event.data?.['errorType'] === ToolErrorType.NO_SPACE_LEFT) {
+ throw new FatalToolExecutionError(
+ 'Error executing tool ' +
+ event.name +
+ ': ' +
+ (displayText || errorMsg),
+ );
+ }
+ handleToolError(
+ event.name,
+ new Error(errorMsg),
+ config,
+ typeof event.data?.['errorType'] === 'string'
+ ? event.data['errorType']
+ : undefined,
+ displayText,
+ );
+ }
+ break;
+ }
+ case 'error': {
+ if (event._meta?.['code'] === 'INVALID_STREAM') {
+ const errorTypeVal = event._meta?.['errorType'];
+ const errorType =
+ typeof errorTypeVal === 'string' ? errorTypeVal : undefined;
+
+ let errorMessage = event.message;
+ if (errorType === 'NO_RESPONSE_TEXT') {
+ errorMessage = TRUE_EMPTY_RESPONSE_MESSAGE;
+ } else if (errorType === 'THINKING_ONLY_RESPONSE') {
+ errorMessage = THINKING_ONLY_COMPRESS_SUGGESTION;
+ } else if (errorType === 'MAX_TOKENS_EXCEEDED') {
+ errorMessage = MAX_TOKENS_EXCEEDED_SUGGESTION;
+ } else if (errorType === 'SAFETY_BLOCKED') {
+ errorMessage = SAFETY_BLOCKED_MESSAGE;
+ } else if (errorType === 'RECITATION_BLOCKED') {
+ errorMessage = RECITATION_BLOCKED_MESSAGE;
+ } else if (errorType === 'OTHER_BLOCKED') {
+ errorMessage = OTHER_BLOCKED_MESSAGE;
+ }
+
+ if (streamFormatter) {
+ streamFormatter.emitEvent({
+ type: JsonStreamEventType.ERROR,
+ timestamp: new Date().toISOString(),
+ severity: 'error',
+ message: errorMessage,
+ });
+ } else if (config.getOutputFormat() === OutputFormat.TEXT) {
+ process.stderr.write(`[ERROR] ${errorMessage}\n`);
+ }
+
+ // Log semantic error telemetry without double-counting requests
+ uiTelemetryService.recordSemanticValidationError(
+ geminiClient.getCurrentSequenceModel() ?? config.getModel(),
+ errorType || 'INVALID_STREAM',
+ );
+
+ // If it's a fatal stream error, we should terminate and output final results
+ emitFinalResult({
+ type: 'INVALID_STREAM',
+ message: errorMessage,
+ });
+ streamEnded = true;
+ break;
+ }
+
+ if (event.fatal) {
+ throw reconstructFatalError(event);
+ }
+
+ const errorCode = event._meta?.['code'];
+
+ if (errorCode === 'AGENT_EXECUTION_BLOCKED') {
+ const blockMessage = `Agent execution blocked: ${event.message.trim()}`;
+ if (config.getOutputFormat() === OutputFormat.TEXT) {
+ process.stderr.write(`[WARNING] ${blockMessage}\n`);
+ } else if (streamFormatter) {
+ streamFormatter.emitEvent({
+ type: JsonStreamEventType.ERROR,
+ timestamp: new Date().toISOString(),
+ severity: 'warning',
+ message: stripAnsi(blockMessage),
+ });
+ }
+ warnings.push(blockMessage);
+ break;
+ }
+
+ const severity =
+ event.status === 'RESOURCE_EXHAUSTED' ? 'error' : 'warning';
+ if (config.getOutputFormat() === OutputFormat.TEXT) {
+ process.stderr.write(`[WARNING] ${event.message}\n`);
+ }
+ if (streamFormatter) {
+ streamFormatter.emitEvent({
+ type: JsonStreamEventType.ERROR,
+ timestamp: new Date().toISOString(),
+ severity,
+ message: stripAnsi(event.message),
+ });
+ }
+ warnings.push(event.message);
+ break;
+ }
+ case 'agent_end': {
+ if (event.reason === 'aborted') {
+ throw new FatalCancellationError('Operation cancelled.');
+ } else if (event.reason === 'max_turns') {
+ const isConfiguredTurnLimit =
+ typeof event.data?.['maxTurns'] === 'number' ||
+ typeof event.data?.['turnCount'] === 'number';
+
+ if (isConfiguredTurnLimit) {
+ throw new FatalTurnLimitedError(
+ 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
+ );
+ } else if (streamFormatter) {
+ streamFormatter.emitEvent({
+ type: JsonStreamEventType.ERROR,
+ timestamp: new Date().toISOString(),
+ severity: 'error',
+ message: 'Maximum session turns exceeded',
+ });
+ }
+ }
+
+ const stopMessage =
+ typeof event.data?.['message'] === 'string'
+ ? event.data['message']
+ : '';
+ if (stopMessage && config.getOutputFormat() === OutputFormat.TEXT) {
+ process.stderr.write(`Agent execution stopped: ${stopMessage}\n`);
+ }
+
+ emitFinalResult();
+ streamEnded = true;
+ break;
+ }
+ case 'initialize':
+ case 'session_update':
+ case 'agent_start':
+ case 'tool_update':
+ case 'elicitation_request':
+ case 'elicitation_response':
+ case 'usage':
+ case 'custom':
+ // Explicitly ignore these non-interactive events
+ break;
+ default:
+ debugLogger.error('Unknown agent event type:', event);
+ event satisfies never;
+ break;
+ }
+ }
+ } catch (error) {
+ errorToHandle = error;
+ } finally {
+ // Cleanup stdin cancellation before other cleanup
+ cleanupStdinCancellation();
+ abortController.signal.removeEventListener('abort', abortSession);
+
+ scheduler?.dispose();
+ consolePatcher.cleanup();
+ coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
+ }
+
+ if (errorToHandle) {
+ handleError(errorToHandle, config);
+ }
+ });
+}
diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts
new file mode 100644
index 0000000000000000000000000000000000000000..35cf5105ab3f34bb2b4a0832171c695bd436668d
--- /dev/null
+++ b/packages/cli/src/nonInteractiveCliCommands.ts
@@ -0,0 +1,113 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { PartListUnion } from '@google/genai';
+import { parseSlashCommand } from './utils/commands.js';
+import {
+ FatalInputError,
+ Logger,
+ uiTelemetryService,
+ type Config,
+} from '@google/gemini-cli-core';
+import { CommandService } from './services/CommandService.js';
+import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js';
+import { FileCommandLoader } from './services/FileCommandLoader.js';
+import { McpPromptLoader } from './services/McpPromptLoader.js';
+import type { CommandContext } from './ui/commands/types.js';
+import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
+import type { LoadedSettings } from './config/settings.js';
+import type { SessionStatsState } from './ui/contexts/SessionContext.js';
+
+/**
+ * Processes a slash command in a non-interactive environment.
+ *
+ * @returns A Promise that resolves to `PartListUnion` if a valid command is
+ * found and results in a prompt, or `undefined` otherwise.
+ * @throws {FatalInputError} if the command result is not supported in
+ * non-interactive mode.
+ */
+export const handleSlashCommand = async (
+ rawQuery: string,
+ abortController: AbortController,
+ config: Config,
+ settings: LoadedSettings,
+): Promise => {
+ const trimmed = rawQuery.trim();
+ if (!trimmed.startsWith('/')) {
+ return;
+ }
+
+ const commandService = await CommandService.create(
+ [
+ new BuiltinCommandLoader(config),
+ new McpPromptLoader(config),
+ new FileCommandLoader(config),
+ ],
+ abortController.signal,
+ );
+ const commands = commandService.getCommands();
+
+ const { commandToExecute, args } = parseSlashCommand(rawQuery, commands);
+
+ if (commandToExecute) {
+ if (commandToExecute.action) {
+ // Not used by custom commands but may be in the future.
+ const sessionStats: SessionStatsState = {
+ sessionId: config?.getSessionId(),
+ sessionStartTime: new Date(),
+ metrics: uiTelemetryService.getMetrics(),
+ lastPromptTokenCount: 0,
+ promptCount: 1,
+ };
+
+ const logger = new Logger(config?.getSessionId() || '', config?.storage);
+
+ const commandContext: CommandContext = {
+ services: {
+ agentContext: config,
+ settings,
+ git: undefined,
+ logger,
+ },
+ ui: createNonInteractiveUI(),
+ session: {
+ stats: sessionStats,
+ sessionShellAllowlist: new Set(),
+ },
+ invocation: {
+ raw: trimmed,
+ name: commandToExecute.name,
+ args,
+ },
+ };
+
+ const result = await commandToExecute.action(commandContext, args);
+
+ if (result) {
+ switch (result.type) {
+ case 'submit_prompt':
+ return result.content;
+ case 'confirm_shell_commands':
+ // This result indicates a command attempted to confirm shell commands.
+ // However note that currently, ShellTool is excluded in non-interactive
+ // mode unless 'YOLO mode' is active, so confirmation actually won't
+ // occur because of YOLO mode.
+ // This ensures that if a command *does* request confirmation (e.g.
+ // in the future with more granular permissions), it's handled appropriately.
+ throw new FatalInputError(
+ 'Exiting due to a confirmation prompt requested by the command.',
+ );
+ default:
+ throw new FatalInputError(
+ 'Exiting due to command result that is not supported in non-interactive mode.',
+ );
+ }
+ }
+ }
+ }
+
+ return;
+};
diff --git a/packages/cli/src/output-redirection.test.ts b/packages/cli/src/output-redirection.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8baa1127f5d58d5ab0696b76e4b91d1aaf811c79
--- /dev/null
+++ b/packages/cli/src/output-redirection.test.ts
@@ -0,0 +1,100 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { initializeOutputListenersAndFlush } from './gemini.js';
+import { coreEvents, CoreEvent, type Config } from '@google/gemini-cli-core';
+
+// Mock core dependencies
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ writeToStdout: vi.fn(),
+ writeToStderr: vi.fn(),
+ };
+});
+
+import { writeToStdout, writeToStderr } from '@google/gemini-cli-core';
+
+describe('Output Redirection', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Clear listeners to simulate a clean state for each test
+ coreEvents.removeAllListeners();
+ });
+
+ afterEach(() => {
+ coreEvents.removeAllListeners();
+ });
+
+ it('should redirect buffered stdout to stderr when output format is json', () => {
+ const mockConfig = {
+ getOutputFormat: () => 'json',
+ } as unknown as Config;
+
+ // Simulate buffered output
+ coreEvents.emitOutput(false, 'informational message');
+ coreEvents.emitOutput(true, 'error message');
+
+ // Initialize listeners and flush
+ initializeOutputListenersAndFlush(mockConfig);
+
+ // Verify informational message was forced to stderr
+ expect(writeToStderr).toHaveBeenCalledWith(
+ 'informational message',
+ undefined,
+ );
+ expect(writeToStderr).toHaveBeenCalledWith('error message', undefined);
+ expect(writeToStdout).not.toHaveBeenCalled();
+ });
+
+ it('should NOT redirect buffered stdout to stderr when output format is NOT json', () => {
+ const mockConfig = {
+ getOutputFormat: () => 'text',
+ } as unknown as Config;
+
+ // Simulate buffered output
+ coreEvents.emitOutput(false, 'regular message');
+
+ // Initialize listeners and flush
+ initializeOutputListenersAndFlush(mockConfig);
+
+ // Verify regular message went to stdout
+ expect(writeToStdout).toHaveBeenCalledWith('regular message', undefined);
+ expect(writeToStderr).not.toHaveBeenCalled();
+ });
+
+ it('should NOT force stdout to stderr when config is undefined (early init/version)', () => {
+ // Simulate buffered output during early init
+ coreEvents.emitOutput(false, 'early init message');
+
+ // Initialize with undefined config
+ initializeOutputListenersAndFlush(undefined);
+
+ // Verify it went to stdout (default behavior)
+ expect(writeToStdout).toHaveBeenCalledWith('early init message', undefined);
+ expect(writeToStderr).not.toHaveBeenCalled();
+ });
+
+ it('should attach ConsoleLog and UserFeedback listeners even if Output already has one', () => {
+ // Manually attach an Output listener
+ coreEvents.on(CoreEvent.Output, vi.fn());
+
+ // Initialize - should still attach ConsoleLog and UserFeedback defaults
+ initializeOutputListenersAndFlush(undefined);
+
+ // Simulate events
+ coreEvents.emitConsoleLog('info', 'stray log');
+ coreEvents.emitFeedback('info', 'stray feedback');
+
+ // Draining happens inside initializeOutputListenersAndFlush for existing backlog,
+ // but here we check the newly attached listeners for immediate emission.
+ expect(writeToStderr).toHaveBeenCalledWith('stray log\n');
+ expect(writeToStderr).toHaveBeenCalledWith('stray feedback\n');
+ });
+});
diff --git a/packages/cli/src/utils/activityLogger.test.ts b/packages/cli/src/utils/activityLogger.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9d73c0d19f1a161eb4eb0e4a3d50e48a8802b3eb
--- /dev/null
+++ b/packages/cli/src/utils/activityLogger.test.ts
@@ -0,0 +1,226 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { ActivityLogger, type NetworkLog } from './activityLogger.js';
+import type { ConsoleLogPayload } from '@google/gemini-cli-core';
+
+describe('ActivityLogger', () => {
+ let logger: ActivityLogger;
+
+ beforeEach(() => {
+ logger = ActivityLogger.getInstance();
+ logger.clearBufferedLogs();
+ });
+
+ it('buffers the last 10 requests with all their events grouped', () => {
+ // Emit 15 requests, each with an initial + response event
+ for (let i = 0; i < 15; i++) {
+ const initial: NetworkLog = {
+ id: `req-${i}`,
+ timestamp: i * 2,
+ method: 'GET',
+ url: 'http://example.com',
+ headers: {},
+ pending: true,
+ };
+ logger.emitNetworkEvent(initial);
+ logger.emitNetworkEvent({
+ id: `req-${i}`,
+ pending: false,
+ response: {
+ status: 200,
+ headers: {},
+ body: 'ok',
+ durationMs: 10,
+ },
+ });
+ }
+
+ const logs = logger.getBufferedLogs();
+ // 10 requests * 2 events each = 20 events
+ expect(logs.network.length).toBe(20);
+ // Oldest kept should be req-5 (first 5 evicted)
+ expect(logs.network[0].id).toBe('req-5');
+ // Last should be req-14
+ expect(logs.network[19].id).toBe('req-14');
+ });
+
+ it('keeps all chunk events for a buffered request', () => {
+ // One request with many chunks
+ logger.emitNetworkEvent({
+ id: 'chunked',
+ timestamp: 1,
+ method: 'POST',
+ url: 'http://example.com',
+ headers: {},
+ pending: true,
+ });
+ for (let i = 0; i < 5; i++) {
+ logger.emitNetworkEvent({
+ id: 'chunked',
+ pending: true,
+ chunk: { index: i, data: `chunk-${i}`, timestamp: 2 + i },
+ });
+ }
+ logger.emitNetworkEvent({
+ id: 'chunked',
+ pending: false,
+ response: { status: 200, headers: {}, body: 'done', durationMs: 50 },
+ });
+
+ const logs = logger.getBufferedLogs();
+ // 1 initial + 5 chunks + 1 response = 7 events, all for 'chunked'
+ expect(logs.network.length).toBe(7);
+ expect(logs.network.every((l) => l.id === 'chunked')).toBe(true);
+ });
+
+ it('buffers only the last 10 console logs', () => {
+ for (let i = 0; i < 15; i++) {
+ const log: ConsoleLogPayload = { content: `log-${i}`, type: 'log' };
+ logger.logConsole(log);
+ }
+
+ const logs = logger.getBufferedLogs();
+ expect(logs.console.length).toBe(10);
+ expect(logs.console[0].content).toBe('log-5');
+ expect(logs.console[9].content).toBe('log-14');
+ });
+
+ it('getBufferedLogs is non-destructive', () => {
+ logger.logConsole({ content: 'test', type: 'log' });
+ const first = logger.getBufferedLogs();
+ const second = logger.getBufferedLogs();
+ expect(first.console.length).toBe(1);
+ expect(second.console.length).toBe(1);
+ });
+
+ it('clearBufferedLogs empties both buffers', () => {
+ logger.logConsole({ content: 'test', type: 'log' });
+ logger.emitNetworkEvent({
+ id: 'r1',
+ timestamp: 1,
+ method: 'GET',
+ url: 'http://example.com',
+ headers: {},
+ });
+ logger.clearBufferedLogs();
+ const logs = logger.getBufferedLogs();
+ expect(logs.console.length).toBe(0);
+ expect(logs.network.length).toBe(0);
+ });
+
+ it('drainBufferedLogs returns and clears atomically', () => {
+ logger.logConsole({ content: 'drain-test', type: 'log' });
+ logger.emitNetworkEvent({
+ id: 'r1',
+ timestamp: 1,
+ method: 'GET',
+ url: 'http://example.com',
+ headers: {},
+ });
+
+ const drained = logger.drainBufferedLogs();
+ expect(drained.console.length).toBe(1);
+ expect(drained.network.length).toBe(1);
+
+ // Buffer should now be empty
+ const after = logger.getBufferedLogs();
+ expect(after.console.length).toBe(0);
+ expect(after.network.length).toBe(0);
+ });
+
+ it('preserves headers and method from Request object when intercepting fetch', async () => {
+ const originalFetch = global.fetch;
+
+ const mockFetch = vi.fn().mockImplementation(() =>
+ Promise.resolve({
+ status: 200,
+ headers: new Headers(),
+ body: null,
+ clone: () => ({
+ body: null,
+ status: 200,
+ headers: new Headers(),
+ text: async () => 'ok',
+ json: async () => ({}),
+ }),
+ } as unknown as Response),
+ );
+
+ global.fetch = mockFetch;
+
+ try {
+ // @ts-expect-error - accessing private property for testing
+ logger.isInterceptionEnabled = false;
+ logger.enable();
+
+ const request = new Request('https://api.example.com/data', {
+ headers: { Authorization: 'Bearer test-token' },
+ method: 'POST',
+ });
+
+ await global.fetch(request);
+
+ expect(mockFetch).toHaveBeenCalled();
+ const [, calledInit] = mockFetch.mock.calls[0];
+
+ expect(calledInit?.headers).toBeDefined();
+ const headers = new Headers(calledInit?.headers as HeadersInit);
+ expect(headers.get('Authorization')).toBe('Bearer test-token');
+ expect(headers.has('x-activity-request-id')).toBe(true);
+ expect(calledInit?.method).toBe('POST');
+ } finally {
+ global.fetch = originalFetch;
+ // @ts-expect-error - reset private property
+ logger.isInterceptionEnabled = false;
+ }
+ });
+
+ it('replaces Request headers with init headers (Fetch spec compliance)', async () => {
+ const originalFetch = global.fetch;
+ const mockFetch = vi.fn().mockImplementation(() =>
+ Promise.resolve({
+ status: 200,
+ headers: new Headers(),
+ body: null,
+ clone: () => ({
+ body: null,
+ status: 200,
+ headers: new Headers(),
+ text: async () => 'ok',
+ }),
+ } as unknown as Response),
+ );
+ global.fetch = mockFetch;
+
+ try {
+ // @ts-expect-error - accessing private property for testing
+ logger.isInterceptionEnabled = false;
+ logger.enable();
+
+ const request = new Request('https://api.example.com/data', {
+ headers: { 'X-Old': 'old-value', 'X-Shared': 'old-shared' },
+ });
+
+ await global.fetch(request, {
+ headers: { 'X-New': 'new-value', 'X-Shared': 'new-shared' },
+ });
+
+ const [, calledInit] = mockFetch.mock.calls[0];
+ const headers = new Headers(calledInit?.headers as HeadersInit);
+
+ expect(headers.get('X-New')).toBe('new-value');
+ expect(headers.get('X-Shared')).toBe('new-shared');
+ expect(headers.has('X-Old')).toBe(false);
+ expect(headers.has('x-activity-request-id')).toBe(true);
+ } finally {
+ global.fetch = originalFetch;
+ // @ts-expect-error - reset private property
+ logger.isInterceptionEnabled = false;
+ }
+ });
+});
diff --git a/packages/cli/src/utils/agentUtils.test.ts b/packages/cli/src/utils/agentUtils.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e62fb7f1f28d606d1949bd5ee105aca1b2381aa9
--- /dev/null
+++ b/packages/cli/src/utils/agentUtils.test.ts
@@ -0,0 +1,150 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+
+vi.mock('../config/settings.js', () => ({
+ SettingScope: {
+ User: 'User',
+ Workspace: 'Workspace',
+ System: 'System',
+ SystemDefaults: 'SystemDefaults',
+ },
+}));
+
+import { renderAgentActionFeedback } from './agentUtils.js';
+import { SettingScope } from '../config/settings.js';
+import type { AgentActionResult } from './agentSettings.js';
+
+describe('agentUtils', () => {
+ describe('renderAgentActionFeedback', () => {
+ const mockFormatScope = (label: string, path: string) =>
+ `[${label}:${path}]`;
+
+ it('should return error message if status is error', () => {
+ const result: AgentActionResult = {
+ status: 'error',
+ agentName: 'my-agent',
+ action: 'enable',
+ modifiedScopes: [],
+ alreadyInStateScopes: [],
+ error: 'Something went wrong',
+ };
+ expect(renderAgentActionFeedback(result, mockFormatScope)).toBe(
+ 'Something went wrong',
+ );
+ });
+
+ it('should return default error message if status is error and no error message provided', () => {
+ const result: AgentActionResult = {
+ status: 'error',
+ agentName: 'my-agent',
+ action: 'enable',
+ modifiedScopes: [],
+ alreadyInStateScopes: [],
+ };
+ expect(renderAgentActionFeedback(result, mockFormatScope)).toBe(
+ 'An error occurred while attempting to enable agent "my-agent".',
+ );
+ });
+
+ it('should return no-op message for enable', () => {
+ const result: AgentActionResult = {
+ status: 'no-op',
+ agentName: 'my-agent',
+ action: 'enable',
+ modifiedScopes: [],
+ alreadyInStateScopes: [],
+ };
+ expect(renderAgentActionFeedback(result, mockFormatScope)).toBe(
+ 'Agent "my-agent" is already enabled.',
+ );
+ });
+
+ it('should return no-op message for disable', () => {
+ const result: AgentActionResult = {
+ status: 'no-op',
+ agentName: 'my-agent',
+ action: 'disable',
+ modifiedScopes: [],
+ alreadyInStateScopes: [],
+ };
+ expect(renderAgentActionFeedback(result, mockFormatScope)).toBe(
+ 'Agent "my-agent" is already disabled.',
+ );
+ });
+
+ it('should return success message for enable (single scope)', () => {
+ const result: AgentActionResult = {
+ status: 'success',
+ agentName: 'my-agent',
+ action: 'enable',
+ modifiedScopes: [
+ { scope: SettingScope.User, path: '/path/to/user/settings' },
+ ],
+ alreadyInStateScopes: [],
+ };
+ expect(renderAgentActionFeedback(result, mockFormatScope)).toBe(
+ 'Agent "my-agent" enabled by setting it to enabled in [user:/path/to/user/settings] settings.',
+ );
+ });
+
+ it('should return success message for enable (two scopes)', () => {
+ const result: AgentActionResult = {
+ status: 'success',
+ agentName: 'my-agent',
+ action: 'enable',
+ modifiedScopes: [
+ { scope: SettingScope.User, path: '/path/to/user/settings' },
+ ],
+ alreadyInStateScopes: [
+ {
+ scope: SettingScope.Workspace,
+ path: '/path/to/workspace/settings',
+ },
+ ],
+ };
+ expect(renderAgentActionFeedback(result, mockFormatScope)).toBe(
+ 'Agent "my-agent" enabled by setting it to enabled in [user:/path/to/user/settings] and [project:/path/to/workspace/settings] settings.',
+ );
+ });
+
+ it('should return success message for disable (single scope)', () => {
+ const result: AgentActionResult = {
+ status: 'success',
+ agentName: 'my-agent',
+ action: 'disable',
+ modifiedScopes: [
+ { scope: SettingScope.User, path: '/path/to/user/settings' },
+ ],
+ alreadyInStateScopes: [],
+ };
+ expect(renderAgentActionFeedback(result, mockFormatScope)).toBe(
+ 'Agent "my-agent" disabled by setting it to disabled in [user:/path/to/user/settings] settings.',
+ );
+ });
+
+ it('should return success message for disable (two scopes)', () => {
+ const result: AgentActionResult = {
+ status: 'success',
+ agentName: 'my-agent',
+ action: 'disable',
+ modifiedScopes: [
+ { scope: SettingScope.User, path: '/path/to/user/settings' },
+ ],
+ alreadyInStateScopes: [
+ {
+ scope: SettingScope.Workspace,
+ path: '/path/to/workspace/settings',
+ },
+ ],
+ };
+ expect(renderAgentActionFeedback(result, mockFormatScope)).toBe(
+ 'Agent "my-agent" is now disabled in both [user:/path/to/user/settings] and [project:/path/to/workspace/settings] settings.',
+ );
+ });
+ });
+});
diff --git a/packages/cli/src/utils/agentUtils.ts b/packages/cli/src/utils/agentUtils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4bcee796d1f6467441603070fb53ec7bc172f508
--- /dev/null
+++ b/packages/cli/src/utils/agentUtils.ts
@@ -0,0 +1,65 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { SettingScope } from '../config/settings.js';
+import type { AgentActionResult } from './agentSettings.js';
+
+/**
+ * Shared logic for building the core agent action message while allowing the
+ * caller to control how each scope and its path are rendered (e.g., bolding or
+ * dimming).
+ *
+ * This function ONLY returns the description of what happened. It is up to the
+ * caller to append any interface-specific guidance.
+ */
+export function renderAgentActionFeedback(
+ result: AgentActionResult,
+ formatScope: (label: string, path: string) => string,
+): string {
+ const { agentName, action, status, error } = result;
+
+ if (status === 'error') {
+ return (
+ error ||
+ `An error occurred while attempting to ${action} agent "${agentName}".`
+ );
+ }
+
+ if (status === 'no-op') {
+ return `Agent "${agentName}" is already ${action === 'enable' ? 'enabled' : 'disabled'}.`;
+ }
+
+ const isEnable = action === 'enable';
+ const actionVerb = isEnable ? 'enabled' : 'disabled';
+ const preposition = isEnable
+ ? 'by setting it to enabled in'
+ : 'by setting it to disabled in';
+
+ const formatScopeItem = (s: { scope: SettingScope; path: string }) => {
+ const label =
+ s.scope === SettingScope.Workspace ? 'project' : s.scope.toLowerCase();
+ return formatScope(label, s.path);
+ };
+
+ const totalAffectedScopes = [
+ ...result.modifiedScopes,
+ ...result.alreadyInStateScopes,
+ ];
+
+ if (totalAffectedScopes.length === 2) {
+ const s1 = formatScopeItem(totalAffectedScopes[0]);
+ const s2 = formatScopeItem(totalAffectedScopes[1]);
+
+ if (isEnable) {
+ return `Agent "${agentName}" ${actionVerb} ${preposition} ${s1} and ${s2} settings.`;
+ } else {
+ return `Agent "${agentName}" is now disabled in both ${s1} and ${s2} settings.`;
+ }
+ }
+
+ const s = formatScopeItem(totalAffectedScopes[0]);
+ return `Agent "${agentName}" ${actionVerb} ${preposition} ${s} settings.`;
+}
diff --git a/packages/cli/src/utils/autoMemory.ts b/packages/cli/src/utils/autoMemory.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9d4a04f632ba7b53825b7a4402c02d12c3f26e46
--- /dev/null
+++ b/packages/cli/src/utils/autoMemory.ts
@@ -0,0 +1,21 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ debugLogger,
+ startMemoryService,
+ type Config,
+} from '@google/gemini-cli-core';
+
+export function startAutoMemoryIfEnabled(config: Config): void {
+ if (!config.isAutoMemoryEnabled()) {
+ return;
+ }
+
+ startMemoryService(config).catch((e) => {
+ debugLogger.error('Failed to start memory service:', e);
+ });
+}
diff --git a/packages/cli/src/utils/cleanup.ts b/packages/cli/src/utils/cleanup.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2f18bdee30f32a9230146387063acefd0e33eb7f
--- /dev/null
+++ b/packages/cli/src/utils/cleanup.ts
@@ -0,0 +1,197 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { promises as fs } from 'node:fs';
+import { join } from 'node:path';
+import {
+ Storage,
+ shutdownTelemetry,
+ isTelemetrySdkInitialized,
+ ExitCodes,
+ resetBrowserSession,
+} from '@google/gemini-cli-core';
+import type { Config } from '@google/gemini-cli-core';
+
+const cleanupFunctions: Array<(() => void) | (() => Promise)> = [];
+const syncCleanupFunctions: Array<() => void> = [];
+let configForTelemetry: Config | null = null;
+let isShuttingDown = false;
+
+export function registerCleanup(fn: (() => void) | (() => Promise)) {
+ cleanupFunctions.push(fn);
+}
+
+export function removeCleanup(fn: (() => void) | (() => Promise)) {
+ const index = cleanupFunctions.indexOf(fn);
+ if (index !== -1) {
+ cleanupFunctions.splice(index, 1);
+ }
+}
+
+export function registerSyncCleanup(fn: () => void) {
+ syncCleanupFunctions.push(fn);
+}
+
+export function removeSyncCleanup(fn: () => void) {
+ const index = syncCleanupFunctions.indexOf(fn);
+ if (index !== -1) {
+ syncCleanupFunctions.splice(index, 1);
+ }
+}
+
+/**
+ * Resets the internal cleanup state for testing purposes.
+ * This allows tests to run in isolation without vi.resetModules().
+ */
+export function resetCleanupForTesting() {
+ cleanupFunctions.length = 0;
+ syncCleanupFunctions.length = 0;
+ configForTelemetry = null;
+ isShuttingDown = false;
+}
+
+export function runSyncCleanup() {
+ for (const fn of syncCleanupFunctions) {
+ try {
+ fn();
+ } catch {
+ // Ignore errors during cleanup.
+ }
+ }
+ syncCleanupFunctions.length = 0;
+}
+
+/**
+ * Register the config instance for telemetry shutdown.
+ * This must be called early in the application lifecycle.
+ */
+export function registerTelemetryConfig(config: Config) {
+ configForTelemetry = config;
+}
+
+export async function runExitCleanup() {
+ // drain stdin to prevent printing garbage on exit
+ // https://github.com/google-gemini/gemini-cli/issues/16801
+ await drainStdin();
+
+ runSyncCleanup();
+ for (const fn of cleanupFunctions) {
+ try {
+ await fn();
+ } catch {
+ // Ignore errors during cleanup.
+ }
+ }
+ cleanupFunctions.length = 0; // Clear the array
+
+ // Close persistent browser sessions before disposing config
+ try {
+ await resetBrowserSession();
+ } catch {
+ // Ignore errors during browser cleanup
+ }
+
+ if (configForTelemetry) {
+ try {
+ await configForTelemetry.dispose();
+ } catch {
+ // Ignore errors during disposal
+ }
+ }
+
+ // IMPORTANT: Shutdown telemetry AFTER all other cleanup functions have run
+ // This ensures SessionEnd hooks and other telemetry are properly flushed
+ if (configForTelemetry && isTelemetrySdkInitialized()) {
+ try {
+ await shutdownTelemetry(configForTelemetry);
+ } catch {
+ // Ignore errors during telemetry shutdown
+ }
+ }
+}
+
+async function drainStdin() {
+ if (!process.stdin?.isTTY) return;
+ // Resume stdin and attach a no-op listener to drain the buffer.
+ // We use removeAllListeners to ensure we don't trigger other handlers.
+ process.stdin
+ .resume()
+ .removeAllListeners('data')
+ .on('data', () => {});
+ // Give it a moment to flush the OS buffer.
+ await new Promise((resolve) => setTimeout(resolve, 50));
+}
+
+/**
+ * Gracefully shuts down the process, ensuring cleanup runs exactly once.
+ * Guards against concurrent shutdown from signals (SIGHUP, SIGTERM, SIGINT)
+ * and TTY loss detection racing each other.
+ *
+ * @see https://github.com/google-gemini/gemini-cli/issues/15874
+ */
+async function gracefulShutdown(_reason: string) {
+ if (isShuttingDown) {
+ return;
+ }
+ isShuttingDown = true;
+
+ await runExitCleanup();
+ process.exit(ExitCodes.SUCCESS);
+}
+
+export function setupSignalHandlers() {
+ process.on('SIGHUP', () => gracefulShutdown('SIGHUP'));
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
+}
+
+export function setupTtyCheck(): () => void {
+ let intervalId: ReturnType | null = null;
+ let isCheckingTty = false;
+
+ intervalId = setInterval(async () => {
+ if (isCheckingTty || isShuttingDown) {
+ return;
+ }
+
+ if (process.env['SANDBOX']) {
+ return;
+ }
+
+ if (!process.stdin.isTTY && !process.stdout.isTTY) {
+ isCheckingTty = true;
+
+ if (intervalId) {
+ clearInterval(intervalId);
+ intervalId = null;
+ }
+
+ await gracefulShutdown('TTY loss');
+ }
+ }, 5000);
+
+ // Don't keep the process alive just for this interval
+ intervalId.unref();
+
+ return () => {
+ if (intervalId) {
+ clearInterval(intervalId);
+ intervalId = null;
+ }
+ };
+}
+
+export async function cleanupCheckpoints() {
+ const storage = new Storage(process.cwd());
+ await storage.initialize();
+ const tempDir = storage.getProjectTempDir();
+ const checkpointsDir = join(tempDir, 'checkpoints');
+ try {
+ await fs.rm(checkpointsDir, { recursive: true, force: true });
+ } catch {
+ // Ignore errors if the directory doesn't exist or fails to delete.
+ }
+}
diff --git a/packages/cli/src/utils/commands.test.ts b/packages/cli/src/utils/commands.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a201962c9a50cc5275fef5ce12a9ce1594cf3c0f
--- /dev/null
+++ b/packages/cli/src/utils/commands.test.ts
@@ -0,0 +1,241 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import { parseSlashCommand } from './commands.js';
+import { CommandKind, type SlashCommand } from '../ui/commands/types.js';
+
+// Mock command structure for testing
+const mockCommands: readonly SlashCommand[] = [
+ {
+ name: 'help',
+ description: 'Show help',
+ action: async () => {},
+ kind: CommandKind.BUILT_IN,
+ },
+ {
+ name: 'commit',
+ description: 'Commit changes',
+ action: async () => {},
+ kind: CommandKind.USER_FILE,
+ },
+ {
+ name: 'memory',
+ description: 'Manage memory',
+ altNames: ['mem'],
+ subCommands: [
+ {
+ name: 'list',
+ description: 'List memory files',
+ action: async () => {},
+ kind: CommandKind.BUILT_IN,
+ },
+ {
+ name: 'clear',
+ description: 'Clear memory',
+ altNames: ['c'],
+ action: async () => {},
+ kind: CommandKind.BUILT_IN,
+ },
+ ],
+ kind: CommandKind.BUILT_IN,
+ },
+];
+
+describe('parseSlashCommand', () => {
+ it('should parse a simple command without arguments', () => {
+ const result = parseSlashCommand('/help', mockCommands);
+ expect(result.commandToExecute?.name).toBe('help');
+ expect(result.args).toBe('');
+ expect(result.canonicalPath).toEqual(['help']);
+ });
+
+ it('should parse a simple command with arguments', () => {
+ const result = parseSlashCommand(
+ '/commit -m "Initial commit"',
+ mockCommands,
+ );
+ expect(result.commandToExecute?.name).toBe('commit');
+ expect(result.args).toBe('-m "Initial commit"');
+ expect(result.canonicalPath).toEqual(['commit']);
+ });
+
+ it('should parse a subcommand', () => {
+ const result = parseSlashCommand('/memory list', mockCommands);
+ expect(result.commandToExecute?.name).toBe('list');
+ expect(result.args).toBe('');
+ expect(result.canonicalPath).toEqual(['memory', 'list']);
+ });
+
+ it('should parse a subcommand with arguments', () => {
+ const result = parseSlashCommand(
+ '/memory list some important data',
+ mockCommands,
+ );
+ expect(result.commandToExecute?.name).toBe('list');
+ expect(result.args).toBe('some important data');
+ expect(result.canonicalPath).toEqual(['memory', 'list']);
+ });
+
+ it('should handle a command alias', () => {
+ const result = parseSlashCommand('/mem list some data', mockCommands);
+ expect(result.commandToExecute?.name).toBe('list');
+ expect(result.args).toBe('some data');
+ expect(result.canonicalPath).toEqual(['memory', 'list']);
+ });
+
+ it('should handle a subcommand alias', () => {
+ const result = parseSlashCommand('/memory c', mockCommands);
+ expect(result.commandToExecute?.name).toBe('clear');
+ expect(result.args).toBe('');
+ expect(result.canonicalPath).toEqual(['memory', 'clear']);
+ });
+
+ it('should return undefined for an unknown command', () => {
+ const result = parseSlashCommand('/unknown', mockCommands);
+ expect(result.commandToExecute).toBeUndefined();
+ expect(result.args).toBe('unknown');
+ expect(result.canonicalPath).toEqual([]);
+ });
+
+ it('should return the parent command if subcommand is unknown', () => {
+ const result = parseSlashCommand(
+ '/memory unknownsub some args',
+ mockCommands,
+ );
+ expect(result.commandToExecute?.name).toBe('memory');
+ expect(result.args).toBe('unknownsub some args');
+ expect(result.canonicalPath).toEqual(['memory']);
+ });
+
+ it('should handle extra whitespace', () => {
+ const result = parseSlashCommand(
+ ' /memory list some data ',
+ mockCommands,
+ );
+ expect(result.commandToExecute?.name).toBe('list');
+ expect(result.args).toBe('some data');
+ expect(result.canonicalPath).toEqual(['memory', 'list']);
+ });
+
+ it('should return undefined if query does not start with a slash', () => {
+ const result = parseSlashCommand('help', mockCommands);
+ expect(result.commandToExecute).toBeUndefined();
+ });
+
+ it('should handle an empty query', () => {
+ const result = parseSlashCommand('', mockCommands);
+ expect(result.commandToExecute).toBeUndefined();
+ });
+
+ it('should handle a query with only a slash', () => {
+ const result = parseSlashCommand('/', mockCommands);
+ expect(result.commandToExecute).toBeUndefined();
+ expect(result.args).toBe('');
+ expect(result.canonicalPath).toEqual([]);
+ });
+
+ describe('backtracking', () => {
+ const backtrackingCommands: readonly SlashCommand[] = [
+ {
+ name: 'parent',
+ description: 'Parent command',
+ kind: CommandKind.BUILT_IN,
+ action: async () => {},
+ subCommands: [
+ {
+ name: 'notakes',
+ description: 'Subcommand that does not take arguments',
+ kind: CommandKind.BUILT_IN,
+ takesArgs: false,
+ action: async () => {},
+ },
+ {
+ name: 'takes',
+ description: 'Subcommand that takes arguments',
+ kind: CommandKind.BUILT_IN,
+ takesArgs: true,
+ action: async () => {},
+ },
+ ],
+ },
+ ];
+
+ it('should backtrack to parent if subcommand has takesArgs: false and args are provided', () => {
+ const result = parseSlashCommand(
+ '/parent notakes some prompt',
+ backtrackingCommands,
+ );
+ expect(result.commandToExecute?.name).toBe('parent');
+ expect(result.args).toBe('notakes some prompt');
+ expect(result.canonicalPath).toEqual(['parent']);
+ });
+
+ it('should NOT backtrack if subcommand has takesArgs: false but NO args are provided', () => {
+ const result = parseSlashCommand('/parent notakes', backtrackingCommands);
+ expect(result.commandToExecute?.name).toBe('notakes');
+ expect(result.args).toBe('');
+ expect(result.canonicalPath).toEqual(['parent', 'notakes']);
+ });
+
+ it('should NOT backtrack if subcommand has takesArgs: true and args are provided', () => {
+ const result = parseSlashCommand(
+ '/parent takes some args',
+ backtrackingCommands,
+ );
+ expect(result.commandToExecute?.name).toBe('takes');
+ expect(result.args).toBe('some args');
+ expect(result.canonicalPath).toEqual(['parent', 'takes']);
+ });
+
+ it('should NOT backtrack if parent has NO action', () => {
+ const noActionCommands: readonly SlashCommand[] = [
+ {
+ name: 'parent',
+ description: 'Parent without action',
+ kind: CommandKind.BUILT_IN,
+ subCommands: [
+ {
+ name: 'notakes',
+ description: 'Subcommand without args',
+ kind: CommandKind.BUILT_IN,
+ takesArgs: false,
+ action: async () => {},
+ },
+ ],
+ },
+ ];
+ const result = parseSlashCommand(
+ '/parent notakes some args',
+ noActionCommands,
+ );
+ // It stays with the subcommand because parent can't handle it
+ expect(result.commandToExecute?.name).toBe('notakes');
+ expect(result.args).toBe('some args');
+ expect(result.canonicalPath).toEqual(['parent', 'notakes']);
+ });
+
+ it('should NOT backtrack if subcommand is NOT marked with takesArgs: false', () => {
+ const result = parseSlashCommand(
+ '/parent takes some args',
+ backtrackingCommands,
+ );
+ expect(result.commandToExecute?.name).toBe('takes');
+ expect(result.args).toBe('some args');
+ expect(result.canonicalPath).toEqual(['parent', 'takes']);
+ });
+
+ it('should backtrack if subcommand has takesArgs: false and args are provided (like /plan copy foo)', () => {
+ const result = parseSlashCommand(
+ '/parent notakes some prompt',
+ backtrackingCommands,
+ );
+ expect(result.commandToExecute?.name).toBe('parent');
+ expect(result.args).toBe('notakes some prompt');
+ expect(result.canonicalPath).toEqual(['parent']);
+ });
+ });
+});
diff --git a/packages/cli/src/utils/commentJson.test.ts b/packages/cli/src/utils/commentJson.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e92b19cb63774e1e9e8932208df290dfdd08d1a8
--- /dev/null
+++ b/packages/cli/src/utils/commentJson.test.ts
@@ -0,0 +1,370 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { updateSettingsFilePreservingFormat } from './commentJson.js';
+import { coreEvents } from '@google/gemini-cli-core';
+
+vi.mock('@google/gemini-cli-core', () => ({
+ coreEvents: {
+ emitFeedback: vi.fn(),
+ },
+}));
+
+describe('commentJson', () => {
+ let tempDir: string;
+ let testFilePath: string;
+
+ beforeEach(() => {
+ // Create a temporary directory for test files
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'preserve-format-test-'));
+ testFilePath = path.join(tempDir, 'settings.json');
+ });
+
+ afterEach(() => {
+ // Clean up temporary directory
+ if (fs.existsSync(tempDir)) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ describe('updateSettingsFilePreservingFormat', () => {
+ it('should preserve comments when updating settings', () => {
+ const originalContent = `{
+ // Model configuration
+ "model": "gemini-2.5-pro",
+ "ui": {
+ // Theme setting
+ "theme": "dark"
+ }
+ }`;
+
+ fs.writeFileSync(testFilePath, originalContent, 'utf-8');
+
+ updateSettingsFilePreservingFormat(testFilePath, {
+ model: 'gemini-2.5-flash',
+ ui: {
+ theme: 'dark',
+ },
+ });
+
+ const updatedContent = fs.readFileSync(testFilePath, 'utf-8');
+
+ expect(updatedContent).toContain('// Model configuration');
+ expect(updatedContent).toContain('// Theme setting');
+ expect(updatedContent).toContain('"model": "gemini-2.5-flash"');
+ expect(updatedContent).toContain('"theme": "dark"');
+ });
+
+ it('should handle nested object updates', () => {
+ const originalContent = `{
+ "ui": {
+ "theme": "dark",
+ "showLineNumbers": true
+ }
+ }`;
+
+ fs.writeFileSync(testFilePath, originalContent, 'utf-8');
+
+ updateSettingsFilePreservingFormat(testFilePath, {
+ ui: {
+ theme: 'light',
+ showLineNumbers: true,
+ },
+ });
+
+ const updatedContent = fs.readFileSync(testFilePath, 'utf-8');
+ expect(updatedContent).toContain('"theme": "light"');
+ expect(updatedContent).toContain('"showLineNumbers": true');
+ });
+
+ it('should add new fields while preserving existing structure', () => {
+ const originalContent = `{
+ // Existing config
+ "model": "gemini-2.5-pro"
+ }`;
+
+ fs.writeFileSync(testFilePath, originalContent, 'utf-8');
+
+ updateSettingsFilePreservingFormat(testFilePath, {
+ model: 'gemini-2.5-pro',
+ newField: 'newValue',
+ });
+
+ const updatedContent = fs.readFileSync(testFilePath, 'utf-8');
+ expect(updatedContent).toContain('// Existing config');
+ expect(updatedContent).toContain('"newField": "newValue"');
+ });
+
+ it('should create file if it does not exist', () => {
+ updateSettingsFilePreservingFormat(testFilePath, {
+ model: 'gemini-2.5-pro',
+ });
+
+ expect(fs.existsSync(testFilePath)).toBe(true);
+ const content = fs.readFileSync(testFilePath, 'utf-8');
+ expect(content).toContain('"model": "gemini-2.5-pro"');
+ });
+
+ it('should handle complex real-world scenario', () => {
+ const complexContent = `{
+ // Settings
+ "model": "gemini-2.5-pro",
+ "mcpServers": {
+ // Active server
+ "context7": {
+ "headers": {
+ "API_KEY": "test-key" // API key
+ }
+ }
+ }
+ }`;
+
+ fs.writeFileSync(testFilePath, complexContent, 'utf-8');
+
+ updateSettingsFilePreservingFormat(testFilePath, {
+ model: 'gemini-2.5-flash',
+ mcpServers: {
+ context7: {
+ headers: {
+ API_KEY: 'new-test-key',
+ },
+ },
+ },
+ newSection: {
+ setting: 'value',
+ },
+ });
+
+ const updatedContent = fs.readFileSync(testFilePath, 'utf-8');
+
+ // Verify comments preserved
+ expect(updatedContent).toContain('// Settings');
+ expect(updatedContent).toContain('// Active server');
+ expect(updatedContent).toContain('// API key');
+
+ // Verify updates applied
+ expect(updatedContent).toContain('"model": "gemini-2.5-flash"');
+ expect(updatedContent).toContain('"newSection"');
+ expect(updatedContent).toContain('"API_KEY": "new-test-key"');
+ });
+
+ it('should handle corrupted JSON files gracefully', () => {
+ const corruptedContent = `{
+ "model": "gemini-2.5-pro",
+ "ui": {
+ "theme": "dark"
+ // Missing closing brace
+ `;
+
+ fs.writeFileSync(testFilePath, corruptedContent, 'utf-8');
+
+ expect(() => {
+ updateSettingsFilePreservingFormat(testFilePath, {
+ model: 'gemini-2.5-flash',
+ });
+ }).not.toThrow();
+
+ expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
+ 'error',
+ 'Error parsing settings file. Please check the JSON syntax.',
+ expect.any(Error),
+ );
+
+ const unchangedContent = fs.readFileSync(testFilePath, 'utf-8');
+ expect(unchangedContent).toBe(corruptedContent);
+ });
+
+ it('should handle array updates while preserving comments', () => {
+ const originalContent = `{
+ // Server configurations
+ "servers": [
+ // First server
+ "server1",
+ "server2" // Second server
+ ]
+ }`;
+
+ fs.writeFileSync(testFilePath, originalContent, 'utf-8');
+
+ updateSettingsFilePreservingFormat(testFilePath, {
+ servers: ['server1', 'server3'],
+ });
+
+ const updatedContent = fs.readFileSync(testFilePath, 'utf-8');
+ expect(updatedContent).toContain('// Server configurations');
+ expect(updatedContent).toContain('"server1"');
+ expect(updatedContent).toContain('"server3"');
+ expect(updatedContent).not.toContain('"server2"');
+ });
+
+ it('should sync nested objects, removing omitted fields', () => {
+ const originalContent = `{
+ // Configuration
+ "model": "gemini-2.5-pro",
+ "ui": {
+ "theme": "dark",
+ "existingSetting": "value"
+ },
+ "preservedField": "keep me"
+ }`;
+
+ fs.writeFileSync(testFilePath, originalContent, 'utf-8');
+
+ updateSettingsFilePreservingFormat(testFilePath, {
+ model: 'gemini-2.5-flash',
+ ui: {
+ theme: 'light',
+ },
+ preservedField: 'keep me',
+ });
+
+ const updatedContent = fs.readFileSync(testFilePath, 'utf-8');
+ expect(updatedContent).toContain('// Configuration');
+ expect(updatedContent).toContain('"model": "gemini-2.5-flash"');
+ expect(updatedContent).toContain('"theme": "light"');
+ expect(updatedContent).not.toContain('"existingSetting": "value"');
+ expect(updatedContent).toContain('"preservedField": "keep me"');
+ });
+
+ it('should handle mcpServers field deletion properly', () => {
+ const originalContent = `{
+ "model": "gemini-2.5-pro",
+ "mcpServers": {
+ // Server to keep
+ "context7": {
+ "command": "node",
+ "args": ["server.js"]
+ },
+ // Server to remove
+ "oldServer": {
+ "command": "old",
+ "args": ["old.js"]
+ }
+ }
+ }`;
+
+ fs.writeFileSync(testFilePath, originalContent, 'utf-8');
+
+ updateSettingsFilePreservingFormat(testFilePath, {
+ model: 'gemini-2.5-pro',
+ mcpServers: {
+ context7: {
+ command: 'node',
+ args: ['server.js'],
+ },
+ },
+ });
+
+ const updatedContent = fs.readFileSync(testFilePath, 'utf-8');
+ expect(updatedContent).toContain('// Server to keep');
+ expect(updatedContent).toContain('"context7"');
+ expect(updatedContent).not.toContain('"oldServer"');
+ // The comment for the removed server should still be preserved
+ expect(updatedContent).toContain('// Server to remove');
+ });
+
+ it('preserves sibling-level commented-out blocks when removing another key', () => {
+ const originalContent = `{
+ "mcpServers": {
+ // "sleep": {
+ // "command": "node",
+ // "args": [
+ // "/Users/testUser/test-mcp-server/sleep-mcp/build/index.js"
+ // ],
+ // "timeout": 300000
+ // },
+ "playwright": {
+ "command": "npx",
+ "args": [
+ "@playwright/mcp@latest",
+ "--headless",
+ "--isolated"
+ ]
+ }
+ }
+ }`;
+
+ fs.writeFileSync(testFilePath, originalContent, 'utf-8');
+
+ updateSettingsFilePreservingFormat(testFilePath, {
+ mcpServers: {},
+ });
+
+ const updatedContent = fs.readFileSync(testFilePath, 'utf-8');
+ expect(updatedContent).toContain('// "sleep": {');
+ expect(updatedContent).toContain('"mcpServers"');
+ expect(updatedContent).not.toContain('"playwright"');
+ });
+
+ it('should handle type conversion from object to array', () => {
+ const originalContent = `{
+ "data": {
+ "key": "value"
+ }
+ }`;
+
+ fs.writeFileSync(testFilePath, originalContent, 'utf-8');
+
+ updateSettingsFilePreservingFormat(testFilePath, {
+ data: ['item1', 'item2'],
+ });
+
+ const updatedContent = fs.readFileSync(testFilePath, 'utf-8');
+ expect(updatedContent).toContain('"data": [');
+ expect(updatedContent).toContain('"item1"');
+ expect(updatedContent).toContain('"item2"');
+ });
+
+ it('should remove both nested and non-nested objects when omitted', () => {
+ const originalContent = `{
+ // Top-level config
+ "topLevelObject": {
+ "field1": "value1",
+ "field2": "value2"
+ },
+ // Parent object
+ "parent": {
+ "nestedObject": {
+ "nestedField1": "value1",
+ "nestedField2": "value2"
+ },
+ "keepThis": "value"
+ },
+ // This should be preserved
+ "preservedObject": {
+ "data": "keep"
+ }
+ }`;
+
+ fs.writeFileSync(testFilePath, originalContent, 'utf-8');
+
+ updateSettingsFilePreservingFormat(testFilePath, {
+ parent: {
+ keepThis: 'value',
+ },
+ preservedObject: {
+ data: 'keep',
+ },
+ });
+
+ const updatedContent = fs.readFileSync(testFilePath, 'utf-8');
+
+ expect(updatedContent).not.toContain('"topLevelObject"');
+
+ expect(updatedContent).not.toContain('"nestedObject"');
+
+ expect(updatedContent).toContain('"keepThis": "value"');
+ expect(updatedContent).toContain('"preservedObject"');
+ expect(updatedContent).toContain('"data": "keep"');
+
+ expect(updatedContent).toContain('// This should be preserved');
+ });
+ });
+});
diff --git a/packages/cli/src/utils/commentJson.ts b/packages/cli/src/utils/commentJson.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c60011b81f92dc0b947810070f414958eebab8fa
--- /dev/null
+++ b/packages/cli/src/utils/commentJson.ts
@@ -0,0 +1,169 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as fs from 'node:fs';
+import { parse, stringify } from 'comment-json';
+import { coreEvents } from '@google/gemini-cli-core';
+
+/**
+ * Type representing an object that may contain Symbol keys for comments.
+ */
+type CommentedRecord = Record;
+
+/**
+ * Updates a JSON file while preserving comments and formatting.
+ */
+export function updateSettingsFilePreservingFormat(
+ filePath: string,
+ updates: Record,
+): void {
+ if (!fs.existsSync(filePath)) {
+ fs.writeFileSync(filePath, JSON.stringify(updates, null, 2), 'utf-8');
+ return;
+ }
+
+ const originalContent = fs.readFileSync(filePath, 'utf-8');
+
+ let parsed: Record;
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ parsed = parse(originalContent) as Record;
+ } catch (error) {
+ coreEvents.emitFeedback(
+ 'error',
+ 'Error parsing settings file. Please check the JSON syntax.',
+ error,
+ );
+ return;
+ }
+
+ const updatedStructure = applyUpdates(parsed, updates);
+ const updatedContent = stringify(updatedStructure, null, 2);
+
+ fs.writeFileSync(filePath, updatedContent, 'utf-8');
+}
+
+/**
+ * When deleting a property from a comment-json parsed object, relocate any
+ * leading/trailing comments that were attached to that property so they are not lost.
+ *
+ * This function re-attaches comments to the next sibling's leading comments if
+ * available, otherwise to the previous sibling's trailing comments, otherwise
+ * to the container's leading/trailing comments.
+ */
+function preserveCommentsOnPropertyDeletion(
+ container: Record,
+ propName: string,
+): void {
+ const target = container as CommentedRecord;
+ const beforeSym = Symbol.for(`before:${propName}`);
+ const afterSym = Symbol.for(`after:${propName}`);
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const beforeComments = target[beforeSym] as unknown[] | undefined;
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const afterComments = target[afterSym] as unknown[] | undefined;
+
+ if (!beforeComments && !afterComments) return;
+
+ const keys = Object.getOwnPropertyNames(container);
+ const idx = keys.indexOf(propName);
+ const nextKey = idx >= 0 && idx + 1 < keys.length ? keys[idx + 1] : undefined;
+ const prevKey = idx > 0 ? keys[idx - 1] : undefined;
+
+ function appendToSymbol(destSym: symbol, comments: unknown[]) {
+ if (!comments || comments.length === 0) return;
+ const existing = target[destSym];
+ target[destSym] = Array.isArray(existing)
+ ? existing.concat(comments)
+ : comments;
+ }
+
+ if (beforeComments && beforeComments.length > 0) {
+ if (nextKey) {
+ appendToSymbol(Symbol.for(`before:${nextKey}`), beforeComments);
+ } else if (prevKey) {
+ appendToSymbol(Symbol.for(`after:${prevKey}`), beforeComments);
+ } else {
+ appendToSymbol(Symbol.for('before'), beforeComments);
+ }
+ delete target[beforeSym];
+ }
+
+ if (afterComments && afterComments.length > 0) {
+ if (nextKey) {
+ appendToSymbol(Symbol.for(`before:${nextKey}`), afterComments);
+ } else if (prevKey) {
+ appendToSymbol(Symbol.for(`after:${prevKey}`), afterComments);
+ } else {
+ appendToSymbol(Symbol.for('after'), afterComments);
+ }
+ delete target[afterSym];
+ }
+}
+
+/**
+ * Applies sync-by-omission semantics: synchronizes base to match desired.
+ * - Adds/updates keys from desired
+ * - Removes keys from base that are not in desired
+ * - Recursively applies to nested objects
+ * - Preserves comments when deleting keys
+ */
+function applyKeyDiff(
+ base: Record,
+ desired: Record,
+): void {
+ for (const existingKey of Object.getOwnPropertyNames(base)) {
+ if (!Object.prototype.hasOwnProperty.call(desired, existingKey)) {
+ preserveCommentsOnPropertyDeletion(base, existingKey);
+ delete base[existingKey];
+ }
+ }
+
+ for (const nextKey of Object.getOwnPropertyNames(desired)) {
+ const nextVal = desired[nextKey];
+ const baseVal = base[nextKey];
+
+ const isObj =
+ typeof nextVal === 'object' &&
+ nextVal !== null &&
+ !Array.isArray(nextVal);
+ const isBaseObj =
+ typeof baseVal === 'object' &&
+ baseVal !== null &&
+ !Array.isArray(baseVal);
+ const isArr = Array.isArray(nextVal);
+ const isBaseArr = Array.isArray(baseVal);
+
+ if (isObj && isBaseObj) {
+ applyKeyDiff(
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ baseVal as Record,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ nextVal as Record,
+ );
+ } else if (isArr && isBaseArr) {
+ // In-place mutate arrays to preserve array-level comments on CommentArray
+ const baseArr = baseVal as unknown[];
+ const desiredArr = nextVal as unknown[];
+ baseArr.length = 0;
+ for (const el of desiredArr) {
+ baseArr.push(el);
+ }
+ } else {
+ base[nextKey] = nextVal;
+ }
+ }
+}
+
+function applyUpdates(
+ current: Record,
+ updates: Record,
+): Record {
+ // Apply sync-by-omission semantics consistently at all levels
+ applyKeyDiff(current, updates);
+ return current;
+}
diff --git a/packages/cli/src/utils/deepMerge.ts b/packages/cli/src/utils/deepMerge.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2eef3b4adaed9ccc6d61f34ed0880926b51ecd50
--- /dev/null
+++ b/packages/cli/src/utils/deepMerge.ts
@@ -0,0 +1,96 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { MergeStrategy } from '../config/settingsSchema.js';
+
+export type Mergeable =
+ | string
+ | number
+ | boolean
+ | null
+ | undefined
+ | object
+ | Mergeable[];
+
+export type MergeableObject = Record;
+
+function isPlainObject(item: unknown): item is MergeableObject {
+ return !!item && typeof item === 'object' && !Array.isArray(item);
+}
+
+function mergeRecursively(
+ target: MergeableObject,
+ source: MergeableObject,
+ getMergeStrategyForPath: (path: string[]) => MergeStrategy | undefined,
+ path: string[] = [],
+) {
+ for (const key of Object.keys(source)) {
+ // JSON.parse can create objects with __proto__ as an own property.
+ // We must skip it to prevent prototype pollution.
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
+ continue;
+ }
+ const srcValue = source[key];
+ if (srcValue === undefined) {
+ continue;
+ }
+ const newPath = [...path, key];
+ const objValue = target[key];
+ const mergeStrategy = getMergeStrategyForPath(newPath);
+
+ if (mergeStrategy === MergeStrategy.SHALLOW_MERGE && objValue && srcValue) {
+ const obj1 =
+ typeof objValue === 'object' && objValue !== null ? objValue : {};
+ const obj2 =
+ typeof srcValue === 'object' && srcValue !== null ? srcValue : {};
+ target[key] = { ...obj1, ...obj2 };
+ continue;
+ }
+
+ if (Array.isArray(objValue)) {
+ const srcArray = Array.isArray(srcValue) ? srcValue : [srcValue];
+ if (mergeStrategy === MergeStrategy.CONCAT) {
+ target[key] = objValue.concat(srcArray);
+ continue;
+ }
+ if (mergeStrategy === MergeStrategy.UNION) {
+ target[key] = [...new Set(objValue.concat(srcArray))];
+ continue;
+ }
+ }
+
+ if (isPlainObject(objValue) && isPlainObject(srcValue)) {
+ mergeRecursively(objValue, srcValue, getMergeStrategyForPath, newPath);
+ } else if (isPlainObject(srcValue)) {
+ target[key] = {};
+ mergeRecursively(
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ target[key] as MergeableObject,
+ srcValue,
+ getMergeStrategyForPath,
+ newPath,
+ );
+ } else {
+ target[key] = srcValue;
+ }
+ }
+ return target;
+}
+
+export function customDeepMerge(
+ getMergeStrategyForPath: (path: string[]) => MergeStrategy | undefined,
+ ...sources: MergeableObject[]
+): MergeableObject {
+ const result: MergeableObject = {};
+
+ for (const source of sources) {
+ if (source) {
+ mergeRecursively(result, source, getMergeStrategyForPath);
+ }
+ }
+
+ return result;
+}
diff --git a/packages/cli/src/utils/envVarResolver.ts b/packages/cli/src/utils/envVarResolver.ts
new file mode 100644
index 0000000000000000000000000000000000000000..286ecbb00b5865ad3f61d3afc763a980150e2585
--- /dev/null
+++ b/packages/cli/src/utils/envVarResolver.ts
@@ -0,0 +1,156 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Resolves environment variables in a string.
+ * Replaces $VAR_NAME, ${VAR_NAME}, and ${VAR_NAME:-DEFAULT_VALUE} with their corresponding
+ * environment variable values. If the environment variable is not defined and no default
+ * value is provided, the original placeholder is preserved.
+ *
+ * @param value - The string that may contain environment variable placeholders
+ * @param customEnv - Optional record of environment variables to use before process.env
+ * @returns The string with environment variables resolved
+ *
+ * @example
+ * resolveEnvVarsInString("Token: $API_KEY") // Returns "Token: secret-123"
+ * resolveEnvVarsInString("URL: ${BASE_URL}/api") // Returns "URL: https://api.example.com/api"
+ * resolveEnvVarsInString("URL: ${MISSING_VAR:-https://default.com}") // Returns "URL: https://default.com"
+ * resolveEnvVarsInString("Missing: $UNDEFINED_VAR") // Returns "Missing: $UNDEFINED_VAR"
+ */
+export function resolveEnvVarsInString(
+ value: string,
+ customEnv?: Record,
+): string {
+ // Regex matches $VAR_NAME, ${VAR_NAME}, and ${VAR_NAME:-DEFAULT_VALUE}
+ const envVarRegex = /\$(?:(\w+)|{([^}]+?)(?::-([^}]*))?})/g;
+
+ return value.replace(
+ envVarRegex,
+ (
+ match: string,
+ varName1?: string,
+ varName2?: string,
+ defaultValue?: string,
+ ): string => {
+ const varName: string = varName1 || varName2 || '';
+
+ if (!varName) {
+ return match;
+ }
+
+ if (customEnv && typeof customEnv[varName] === 'string') {
+ return customEnv[varName];
+ }
+ if (process && process.env) {
+ const val = process.env[varName];
+ if (typeof val === 'string') {
+ return val;
+ }
+ }
+ if (defaultValue !== undefined) {
+ return defaultValue;
+ }
+ return match;
+ },
+ );
+}
+
+/**
+ * Recursively resolves environment variables in an object of any type.
+ * Handles strings, arrays, nested objects, and preserves other primitive types.
+ * Protected against circular references using a WeakSet to track visited objects.
+ *
+ * @param obj - The object to process for environment variable resolution
+ * @returns A new object with environment variables resolved
+ *
+ * @example
+ * const config = {
+ * server: {
+ * host: "$HOST",
+ * port: "${PORT}",
+ * enabled: true,
+ * tags: ["$ENV", "api"]
+ * }
+ * };
+ * const resolved = resolveEnvVarsInObject(config);
+ */
+export function resolveEnvVarsInObject(
+ obj: T,
+ customEnv?: Record,
+): T {
+ return resolveEnvVarsInObjectInternal(obj, new WeakSet(), customEnv);
+}
+
+/**
+ * Internal implementation of resolveEnvVarsInObject with circular reference protection.
+ *
+ * @param obj - The object to process
+ * @param visited - WeakSet to track visited objects and prevent circular references
+ * @returns A new object with environment variables resolved
+ */
+function resolveEnvVarsInObjectInternal(
+ obj: T,
+ visited: WeakSet