SaylorTwift HF Staff commited on
Commit
7421850
·
verified ·
1 Parent(s): 38be44d

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .github/actions/create-pull-request/action.yml +55 -0
  2. .github/actions/post-coverage-comment/action.yml +114 -0
  3. .github/actions/publish-release/action.yml +359 -0
  4. .github/actions/push-docker/action.yml +75 -0
  5. .github/actions/push-sandbox/action.yml +114 -0
  6. .github/actions/run-tests/action.yml +41 -0
  7. .github/actions/setup-npmrc/action.yml +24 -0
  8. .github/actions/tag-npm-release/action.yml +139 -0
  9. .github/actions/verify-release/action.yml +103 -0
  10. packages/a2a-server/GEMINI.md +22 -0
  11. packages/a2a-server/README.md +5 -0
  12. packages/a2a-server/development-extension-rfc.md +509 -0
  13. packages/a2a-server/index.ts +7 -0
  14. packages/a2a-server/package.json +52 -0
  15. packages/a2a-server/src/agent/executor.test.ts +587 -0
  16. packages/a2a-server/src/agent/executor.ts +939 -0
  17. packages/a2a-server/src/agent/race-condition.test.ts +173 -0
  18. packages/a2a-server/src/agent/task-event-driven.test.ts +762 -0
  19. packages/a2a-server/src/agent/task.test.ts +916 -0
  20. packages/a2a-server/src/agent/task.ts +1305 -0
  21. packages/a2a-server/src/commands/command-registry.test.ts +142 -0
  22. packages/a2a-server/src/commands/command-registry.ts +51 -0
  23. packages/a2a-server/src/commands/extensions.test.ts +87 -0
  24. packages/a2a-server/src/commands/extensions.ts +41 -0
  25. packages/a2a-server/src/commands/init.test.ts +193 -0
  26. packages/a2a-server/src/commands/init.ts +169 -0
  27. packages/a2a-server/src/commands/memory.test.ts +114 -0
  28. packages/a2a-server/src/commands/memory.ts +74 -0
  29. packages/a2a-server/src/commands/restore.test.ts +137 -0
  30. packages/a2a-server/src/commands/restore.ts +156 -0
  31. packages/a2a-server/src/commands/types.ts +41 -0
  32. packages/a2a-server/src/config/config.test.ts +585 -0
  33. packages/a2a-server/src/config/config.ts +708 -0
  34. packages/a2a-server/src/config/extension.ts +157 -0
  35. packages/a2a-server/src/config/rce_vulnerability.test.ts +115 -0
  36. packages/a2a-server/src/config/settings.test.ts +235 -0
  37. packages/a2a-server/src/config/settings.ts +204 -0
  38. packages/a2a-server/src/config/workspace_trust.test.ts +179 -0
  39. packages/a2a-server/src/http/app.test.ts +1266 -0
  40. packages/a2a-server/src/http/app.ts +435 -0
  41. packages/a2a-server/src/http/endpoints.test.ts +245 -0
  42. packages/a2a-server/src/http/requestStorage.ts +10 -0
  43. packages/a2a-server/src/http/server.ts +34 -0
  44. packages/a2a-server/src/index.ts +9 -0
  45. packages/a2a-server/src/persistence/gcs.test.ts +446 -0
  46. packages/a2a-server/src/persistence/gcs.ts +319 -0
  47. packages/a2a-server/src/types.ts +190 -0
  48. packages/a2a-server/src/utils/executor_utils.ts +51 -0
  49. packages/a2a-server/src/utils/logger.ts +28 -0
  50. packages/a2a-server/src/utils/path_utils.ts +62 -0
.github/actions/create-pull-request/action.yml ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: 'Create Pull Request'
2
+ description: 'Creates a pull request.'
3
+
4
+ inputs:
5
+ branch-name:
6
+ description: 'The name of the branch to create the PR from.'
7
+ required: true
8
+ pr-title:
9
+ description: 'The title of the pull request.'
10
+ required: true
11
+ pr-body:
12
+ description: 'The body of the pull request.'
13
+ required: true
14
+ base-branch:
15
+ description: 'The branch to merge into.'
16
+ required: true
17
+ default: 'main'
18
+ github-token:
19
+ description: 'The GitHub token to use for creating the pull request.'
20
+ required: true
21
+ dry-run:
22
+ description: 'Whether to run in dry-run mode.'
23
+ required: false
24
+ default: 'false'
25
+ working-directory:
26
+ description: 'The working directory to run the commands in.'
27
+ required: false
28
+ default: '.'
29
+
30
+ runs:
31
+ using: 'composite'
32
+ steps:
33
+ - name: 'Creates a Pull Request'
34
+ if: "inputs.dry-run != 'true'"
35
+ env:
36
+ GH_TOKEN: '${{ inputs.github-token }}'
37
+ INPUTS_BRANCH_NAME: '${{ inputs.branch-name }}'
38
+ INPUTS_PR_TITLE: '${{ inputs.pr-title }}'
39
+ INPUTS_PR_BODY: '${{ inputs.pr-body }}'
40
+ INPUTS_BASE_BRANCH: '${{ inputs.base-branch }}'
41
+ shell: 'bash'
42
+ working-directory: '${{ inputs.working-directory }}'
43
+ run: |
44
+ set -e
45
+ if ! git ls-remote --exit-code --heads origin "${INPUTS_BRANCH_NAME}"; then
46
+ echo "::error::Branch '${INPUTS_BRANCH_NAME}' does not exist on the remote repository."
47
+ exit 1
48
+ fi
49
+ PR_URL=$(gh pr create \
50
+ --title "${INPUTS_PR_TITLE}" \
51
+ --body "${INPUTS_PR_BODY}" \
52
+ --base "${INPUTS_BASE_BRANCH}" \
53
+ --head "${INPUTS_BRANCH_NAME}" \
54
+ --fill)
55
+ gh pr merge "$PR_URL" --auto
.github/actions/post-coverage-comment/action.yml ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: 'Post Coverage Comment Action'
2
+ description: 'Prepares and posts a code coverage comment to a PR.'
3
+
4
+ inputs:
5
+ cli_json_file:
6
+ description: 'Path to CLI coverage-summary.json'
7
+ required: true
8
+ core_json_file:
9
+ description: 'Path to Core coverage-summary.json'
10
+ required: true
11
+ cli_full_text_summary_file:
12
+ description: 'Path to CLI full-text-summary.txt'
13
+ required: true
14
+ core_full_text_summary_file:
15
+ description: 'Path to Core full-text-summary.txt'
16
+ required: true
17
+ node_version:
18
+ description: 'Node.js version for context in messages'
19
+ required: true
20
+ os:
21
+ description: 'The os for context in messages'
22
+ required: true
23
+ github_token:
24
+ description: 'GitHub token for posting comments'
25
+ required: true
26
+
27
+ runs:
28
+ using: 'composite'
29
+ steps:
30
+ - name: 'Prepare Coverage Comment'
31
+ id: 'prep_coverage_comment'
32
+ shell: 'bash'
33
+ env:
34
+ CLI_JSON_FILE: '${{ inputs.cli_json_file }}'
35
+ CORE_JSON_FILE: '${{ inputs.core_json_file }}'
36
+ CLI_FULL_TEXT_SUMMARY_FILE: '${{ inputs.cli_full_text_summary_file }}'
37
+ CORE_FULL_TEXT_SUMMARY_FILE: '${{ inputs.core_full_text_summary_file }}'
38
+ COMMENT_FILE: 'coverage-comment.md'
39
+ NODE_VERSION: '${{ inputs.node_version }}'
40
+ OS: '${{ inputs.os }}'
41
+ run: |-
42
+ # Extract percentages using jq for the main table
43
+ if [ -f "${CLI_JSON_FILE}" ]; then
44
+ cli_lines_pct="$(jq -r '.total.lines.pct' "${CLI_JSON_FILE}")"
45
+ cli_statements_pct="$(jq -r '.total.statements.pct' "${CLI_JSON_FILE}")"
46
+ cli_functions_pct="$(jq -r '.total.functions.pct' "${CLI_JSON_FILE}")"
47
+ cli_branches_pct="$(jq -r '.total.branches.pct' "${CLI_JSON_FILE}")"
48
+ else
49
+ cli_lines_pct="N/A"
50
+ cli_statements_pct="N/A"
51
+ cli_functions_pct="N/A"
52
+ cli_branches_pct="N/A"
53
+ echo "CLI coverage-summary.json not found at: ${CLI_JSON_FILE}" >&2 # Error to stderr
54
+ fi
55
+
56
+ if [ -f "${CORE_JSON_FILE}" ]; then
57
+ core_lines_pct="$(jq -r '.total.lines.pct' "${CORE_JSON_FILE}")"
58
+ core_statements_pct="$(jq -r '.total.statements.pct' "${CORE_JSON_FILE}")"
59
+ core_functions_pct="$(jq -r '.total.functions.pct' "${CORE_JSON_FILE}")"
60
+ core_branches_pct="$(jq -r '.total.branches.pct' "${CORE_JSON_FILE}")"
61
+ else
62
+ core_lines_pct="N/A"
63
+ core_statements_pct="N/A"
64
+ core_functions_pct="N/A"
65
+ core_branches_pct="N/A"
66
+ echo "Core coverage-summary.json not found at: ${CORE_JSON_FILE}" >&2 # Error to stderr
67
+ fi
68
+
69
+ echo "## Code Coverage Summary" > "${COMMENT_FILE}"
70
+ echo "" >> "${COMMENT_FILE}"
71
+ echo "| Package | Lines | Statements | Functions | Branches |" >> "${COMMENT_FILE}"
72
+ echo "|---|---|---|---|---|" >> "${COMMENT_FILE}"
73
+ echo "| CLI | ${cli_lines_pct}% | ${cli_statements_pct}% | ${cli_functions_pct}% | ${cli_branches_pct}% |" >> "${COMMENT_FILE}"
74
+ echo "| Core | ${core_lines_pct}% | ${core_statements_pct}% | ${core_functions_pct}% | ${core_branches_pct}% |" >> "${COMMENT_FILE}"
75
+ echo "" >> "${COMMENT_FILE}"
76
+
77
+ # CLI Package - Collapsible Section (with full text summary from file)
78
+ echo "<details>" >> "${COMMENT_FILE}"
79
+ echo "<summary>CLI Package - Full Text Report</summary>" >> "${COMMENT_FILE}"
80
+ echo "" >> "${COMMENT_FILE}"
81
+ echo '```text' >> "${COMMENT_FILE}"
82
+ if [ -f "${CLI_FULL_TEXT_SUMMARY_FILE}" ]; then
83
+ cat "${CLI_FULL_TEXT_SUMMARY_FILE}" >> "${COMMENT_FILE}"
84
+ else
85
+ echo "CLI full-text-summary.txt not found at: ${CLI_FULL_TEXT_SUMMARY_FILE}" >> "${COMMENT_FILE}"
86
+ fi
87
+ echo '```' >> "${COMMENT_FILE}"
88
+ echo "</details>" >> "${COMMENT_FILE}"
89
+ echo "" >> "${COMMENT_FILE}"
90
+
91
+ # Core Package - Collapsible Section (with full text summary from file)
92
+ echo "<details>" >> "${COMMENT_FILE}"
93
+ echo "<summary>Core Package - Full Text Report</summary>" >> "${COMMENT_FILE}"
94
+ echo "" >> "${COMMENT_FILE}"
95
+ echo '```text' >> "${COMMENT_FILE}"
96
+ if [ -f "${CORE_FULL_TEXT_SUMMARY_FILE}" ]; then
97
+ cat "${CORE_FULL_TEXT_SUMMARY_FILE}" >> "${COMMENT_FILE}"
98
+ else
99
+ echo "Core full-text-summary.txt not found at: ${CORE_FULL_TEXT_SUMMARY_FILE}" >> "${COMMENT_FILE}"
100
+ fi
101
+ echo '```' >> "${COMMENT_FILE}"
102
+ echo "</details>" >> "${COMMENT_FILE}"
103
+ echo "" >> "${COMMENT_FILE}"
104
+
105
+ echo "_For detailed HTML reports, please see the 'coverage-reports-${NODE_VERSION}-${OS}' artifact from the main CI run._" >> "${COMMENT_FILE}"
106
+
107
+ - name: 'Post Coverage Comment'
108
+ uses: 'thollander/actions-comment-pull-request@65f9e5c9a1f2cd378bd74b2e057c9736982a8e74' # ratchet:thollander/actions-comment-pull-request@v3
109
+ if: |-
110
+ ${{ always() }}
111
+ with:
112
+ file-path: 'coverage-comment.md' # Use the generated file directly
113
+ comment-tag: 'code-coverage-summary'
114
+ github-token: '${{ inputs.github_token }}'
.github/actions/publish-release/action.yml ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: 'Publish Release'
2
+ description: 'Builds, prepares, and publishes the gemini-cli packages to npm and creates a GitHub release.'
3
+
4
+ inputs:
5
+ release-version:
6
+ description: 'The version to release (e.g., 0.1.11).'
7
+ required: true
8
+ npm-tag:
9
+ description: 'The npm tag to publish with (e.g., latest, preview, nightly).'
10
+ required: true
11
+ wombat-token-core:
12
+ description: 'The npm token for the cli-core package.'
13
+ required: true
14
+ wombat-token-cli:
15
+ description: 'The npm token for the cli package.'
16
+ required: true
17
+ wombat-token-a2a-server:
18
+ description: 'The npm token for the a2a package.'
19
+ required: true
20
+ github-token:
21
+ description: 'The GitHub token for creating the release.'
22
+ required: true
23
+ github-release-token:
24
+ description: 'The GitHub token used specifically for creating the GitHub release (to trigger other workflows).'
25
+ required: false
26
+ dry-run:
27
+ description: 'Whether to run in dry-run mode.'
28
+ type: 'string'
29
+ required: true
30
+ release-tag:
31
+ description: 'The release tag for the release (e.g., v0.1.11).'
32
+ required: true
33
+ previous-tag:
34
+ description: 'The previous tag to use for generating release notes.'
35
+ required: true
36
+ skip-github-release:
37
+ description: 'Whether to skip creating a GitHub release.'
38
+ type: 'boolean'
39
+ required: false
40
+ default: false
41
+ working-directory:
42
+ description: 'The working directory to run the steps in.'
43
+ required: false
44
+ default: '.'
45
+ force-skip-tests:
46
+ description: 'Skip tests and validation'
47
+ required: false
48
+ default: false
49
+ skip-branch-cleanup:
50
+ description: 'Whether to skip cleaning up the release branch.'
51
+ type: 'boolean'
52
+ required: false
53
+ default: false
54
+ gemini_api_key:
55
+ description: 'The API key for running integration tests.'
56
+ required: true
57
+ npm-registry-publish-url:
58
+ description: 'npm registry publish url'
59
+ required: true
60
+ npm-registry-url:
61
+ description: 'npm registry url'
62
+ required: true
63
+ npm-registry-scope:
64
+ description: 'npm registry scope'
65
+ required: true
66
+ cli-package-name:
67
+ description: 'The name of the cli package.'
68
+ required: true
69
+ core-package-name:
70
+ description: 'The name of the core package.'
71
+ required: true
72
+ a2a-package-name:
73
+ description: 'The name of the a2a package.'
74
+ required: true
75
+ runs:
76
+ using: 'composite'
77
+ steps:
78
+ - name: '👤 Configure Git User'
79
+ working-directory: '${{ inputs.working-directory }}'
80
+ shell: 'bash'
81
+ run: |
82
+ git config user.name "gemini-cli-robot"
83
+ git config user.email "gemini-cli-robot@google.com"
84
+
85
+ - name: '🌿 Create and switch to a release branch'
86
+ working-directory: '${{ inputs.working-directory }}'
87
+ id: 'release_branch'
88
+ shell: 'bash'
89
+ run: |
90
+ BRANCH_NAME="release/${INPUTS_RELEASE_TAG}"
91
+ git switch -c "${BRANCH_NAME}"
92
+ echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
93
+ env:
94
+ INPUTS_RELEASE_TAG: '${{ inputs.release-tag }}'
95
+
96
+ - name: '⬆️ Update package versions'
97
+ working-directory: '${{ inputs.working-directory }}'
98
+ shell: 'bash'
99
+ run: |
100
+ npm run release:version "${INPUTS_RELEASE_VERSION}"
101
+ env:
102
+ INPUTS_RELEASE_VERSION: '${{ inputs.release-version }}'
103
+
104
+ - name: '💾 Commit and Conditionally Push package versions'
105
+ working-directory: '${{ inputs.working-directory }}'
106
+ shell: 'bash'
107
+ env:
108
+ BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
109
+ DRY_RUN: '${{ inputs.dry-run }}'
110
+ RELEASE_TAG: '${{ inputs.release-tag }}'
111
+ GIT_PUSH_TOKEN: '${{ inputs.github-release-token || inputs.github-token }}'
112
+ run: |-
113
+ set -e
114
+ git add package.json package-lock.json packages/*/package.json
115
+ git commit -m "chore(release): ${RELEASE_TAG}"
116
+ if [[ "${DRY_RUN}" == "false" ]]; then
117
+ echo "Pushing release branch to remote..."
118
+ git push "https://x-access-token:${GIT_PUSH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:${BRANCH_NAME}" --follow-tags
119
+ else
120
+ echo "Dry run enabled. Skipping push."
121
+ fi
122
+
123
+ - name: '🛠️ Build and Prepare Packages'
124
+ working-directory: '${{ inputs.working-directory }}'
125
+ shell: 'bash'
126
+ run: |
127
+ npm run build:packages
128
+ npm run prepare:package
129
+
130
+ - name: '🎁 Bundle'
131
+ working-directory: '${{ inputs.working-directory }}'
132
+ shell: 'bash'
133
+ run: |
134
+ npm run bundle
135
+
136
+ # TODO: Refactor this github specific publishing script to be generalized based upon inputs.
137
+ - name: '📦 Prepare for GitHub release'
138
+ if: "inputs.npm-registry-url == 'https://npm.pkg.github.com/'"
139
+ working-directory: '${{ inputs.working-directory }}'
140
+ shell: 'bash'
141
+ run: |
142
+ node ${{ github.workspace }}/scripts/prepare-github-release.js
143
+
144
+ - name: 'Configure npm for publishing to npm'
145
+ uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020'
146
+ with:
147
+ node-version-file: '${{ inputs.working-directory }}/.nvmrc'
148
+ registry-url: '${{inputs.npm-registry-publish-url}}'
149
+ scope: '${{inputs.npm-registry-scope}}'
150
+
151
+ - name: 'Get core Token'
152
+ uses: './.github/actions/npm-auth-token'
153
+ id: 'core-token'
154
+ with:
155
+ package-name: '${{ inputs.core-package-name }}'
156
+ github-token: '${{ inputs.github-token }}'
157
+ wombat-token-core: '${{ inputs.wombat-token-core }}'
158
+ wombat-token-cli: '${{ inputs.wombat-token-cli }}'
159
+ wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}'
160
+
161
+ - name: '📦 Publish CORE to NPM'
162
+ working-directory: '${{ inputs.working-directory }}'
163
+ env:
164
+ NODE_AUTH_TOKEN: '${{ steps.core-token.outputs.auth-token }}'
165
+ INPUTS_DRY_RUN: '${{ inputs.dry-run }}'
166
+ INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}'
167
+ shell: 'bash'
168
+ run: |
169
+ npm publish \
170
+ --ignore-scripts \
171
+ --dry-run="${INPUTS_DRY_RUN}" \
172
+ --workspace="${INPUTS_CORE_PACKAGE_NAME}" \
173
+ --tag staging-tmp
174
+ if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
175
+ 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)"
176
+ fi
177
+
178
+ - name: '🔗 Install latest core package'
179
+ working-directory: '${{ inputs.working-directory }}'
180
+ if: "${{ inputs.dry-run != 'true' }}"
181
+ shell: 'bash'
182
+ run: |
183
+ npm install "${INPUTS_CORE_PACKAGE_NAME}@${INPUTS_RELEASE_VERSION}" \
184
+ --workspace="${INPUTS_CLI_PACKAGE_NAME}" \
185
+ --workspace="${INPUTS_A2A_PACKAGE_NAME}" \
186
+ --save-exact
187
+ env:
188
+ INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}'
189
+ INPUTS_RELEASE_VERSION: '${{ inputs.release-version }}'
190
+ INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}'
191
+ INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}'
192
+
193
+ - name: '📦 Prepare bundled CLI for npm release'
194
+ if: "inputs.npm-registry-url != 'https://npm.pkg.github.com/'"
195
+ working-directory: '${{ inputs.working-directory }}'
196
+ shell: 'bash'
197
+ run: |
198
+ node ${{ github.workspace }}/scripts/prepare-npm-release.js
199
+
200
+ - name: '📦 Pack CLI for verification'
201
+ if: "inputs.dry-run != 'true' && inputs.force-skip-tests != 'true'"
202
+ working-directory: '${{ inputs.working-directory }}'
203
+ shell: 'bash'
204
+ run: |
205
+ npm pack --workspace="${INPUTS_CLI_PACKAGE_NAME}"
206
+ # We restore the package.json so that `npm ci` in verify-release doesn't fail due to deleted dependencies
207
+ git checkout packages/cli/package.json
208
+ env:
209
+ INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}'
210
+
211
+ - name: '🔬 Verify NPM release by version'
212
+ uses: './.github/actions/verify-release'
213
+ if: "${{ inputs.dry-run != 'true' && inputs.force-skip-tests != 'true' }}"
214
+ with:
215
+ npm-package: './google-gemini-cli-${{ inputs.release-version }}.tgz'
216
+ expected-version: '${{ inputs.release-version }}'
217
+ working-directory: '${{ inputs.working-directory }}'
218
+ gemini_api_key: '${{ inputs.gemini_api_key }}'
219
+ github-token: '${{ inputs.github-token }}'
220
+ npm-registry-url: '${{ inputs.npm-registry-url }}'
221
+ npm-registry-scope: '${{ inputs.npm-registry-scope }}'
222
+
223
+ - name: 'Get CLI Token'
224
+ uses: './.github/actions/npm-auth-token'
225
+ id: 'cli-token'
226
+ with:
227
+ package-name: '${{ inputs.cli-package-name }}'
228
+ github-token: '${{ inputs.github-token }}'
229
+ wombat-token-core: '${{ inputs.wombat-token-core }}'
230
+ wombat-token-cli: '${{ inputs.wombat-token-cli }}'
231
+ wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}'
232
+
233
+ - name: '📦 Publish CLI'
234
+ working-directory: '${{ inputs.working-directory }}'
235
+ env:
236
+ NODE_AUTH_TOKEN: '${{ steps.cli-token.outputs.auth-token }}'
237
+ INPUTS_DRY_RUN: '${{ inputs.dry-run }}'
238
+ INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}'
239
+ INPUTS_RELEASE_VERSION: '${{ inputs.release-version }}'
240
+ shell: 'bash'
241
+ run: |
242
+ if [ -f "google-gemini-cli-${INPUTS_RELEASE_VERSION}.tgz" ]; then
243
+ PUBLISH_TARGET="google-gemini-cli-${INPUTS_RELEASE_VERSION}.tgz"
244
+ else
245
+ PUBLISH_TARGET="--workspace=${INPUTS_CLI_PACKAGE_NAME}"
246
+ fi
247
+
248
+ npm publish \
249
+ --ignore-scripts \
250
+ --dry-run="${INPUTS_DRY_RUN}" \
251
+ ${PUBLISH_TARGET} \
252
+ --tag staging-tmp
253
+ if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
254
+ 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)"
255
+ fi
256
+
257
+ - name: 'Get a2a-server Token'
258
+ uses: './.github/actions/npm-auth-token'
259
+ id: 'a2a-token'
260
+ with:
261
+ package-name: '${{ inputs.a2a-package-name }}'
262
+ github-token: '${{ inputs.github-token }}'
263
+ wombat-token-core: '${{ inputs.wombat-token-core }}'
264
+ wombat-token-cli: '${{ inputs.wombat-token-cli }}'
265
+ wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}'
266
+
267
+ - name: '📦 Publish a2a'
268
+ working-directory: '${{ inputs.working-directory }}'
269
+ env:
270
+ NODE_AUTH_TOKEN: '${{ steps.a2a-token.outputs.auth-token }}'
271
+ INPUTS_DRY_RUN: '${{ inputs.dry-run }}'
272
+ INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}'
273
+ shell: 'bash'
274
+ # Tag staging for initial release
275
+ run: |
276
+ npm publish \
277
+ --ignore-scripts \
278
+ --dry-run="${INPUTS_DRY_RUN}" \
279
+ --workspace="${INPUTS_A2A_PACKAGE_NAME}" \
280
+ --tag staging-tmp
281
+ if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
282
+ 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)"
283
+ fi
284
+
285
+ - name: '🏷️ Tag release'
286
+ uses: './.github/actions/tag-npm-release'
287
+ with:
288
+ channel: '${{ inputs.npm-tag }}'
289
+ version: '${{ inputs.release-version }}'
290
+ dry-run: '${{ inputs.dry-run }}'
291
+ github-token: '${{ inputs.github-token }}'
292
+ wombat-token-core: '${{ inputs.wombat-token-core }}'
293
+ wombat-token-cli: '${{ inputs.wombat-token-cli }}'
294
+ wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}'
295
+ cli-package-name: '${{ inputs.cli-package-name }}'
296
+ core-package-name: '${{ inputs.core-package-name }}'
297
+ a2a-package-name: '${{ inputs.a2a-package-name }}'
298
+ working-directory: '${{ inputs.working-directory }}'
299
+
300
+ - name: '🎉 Create GitHub Release'
301
+ working-directory: '${{ inputs.working-directory }}'
302
+ if: "${{ inputs.dry-run != 'true' && inputs.skip-github-release != 'true' && inputs.npm-tag != 'dev' && inputs.npm-registry-url != 'https://npm.pkg.github.com/' }}"
303
+ env:
304
+ GITHUB_TOKEN: '${{ inputs.github-release-token || inputs.github-token }}'
305
+ INPUTS_RELEASE_TAG: '${{ inputs.release-tag }}'
306
+ STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
307
+ INPUTS_PREVIOUS_TAG: '${{ inputs.previous-tag }}'
308
+ shell: 'bash'
309
+ run: |
310
+ rm -f gemini-cli-bundle.zip
311
+ (cd bundle && chmod +x gemini.js && zip -r ../gemini-cli-bundle.zip .)
312
+
313
+ echo "Testing the generated bundle archive..."
314
+ rm -rf test-bundle
315
+ mkdir -p test-bundle
316
+ unzip -q gemini-cli-bundle.zip -d test-bundle
317
+
318
+ # Verify it runs and outputs a version
319
+ BUNDLE_VERSION=$(node test-bundle/gemini.js --version | xargs)
320
+ echo "Bundle version output: ${BUNDLE_VERSION}"
321
+ if [[ -z "${BUNDLE_VERSION}" ]]; then
322
+ echo "Error: Bundle failed to execute or return version."
323
+ exit 1
324
+ fi
325
+ rm -rf test-bundle
326
+
327
+ RELEASE_ASSETS=("gemini-cli-bundle.zip")
328
+
329
+ # Check for and prepare macOS binaries if they exist
330
+ for arch in arm64 x64; do
331
+ BINARY_PATH="dist/darwin-${arch}/gemini"
332
+ if [[ -f "$BINARY_PATH" ]]; then
333
+ chmod +x "$BINARY_PATH"
334
+ ZIP_NAME="gemini-darwin-${arch}-unsigned.zip"
335
+ zip -j "$ZIP_NAME" "$BINARY_PATH"
336
+ RELEASE_ASSETS+=("$ZIP_NAME")
337
+ fi
338
+ done
339
+
340
+ gh release create "${INPUTS_RELEASE_TAG}" \
341
+ "${RELEASE_ASSETS[@]}" \
342
+ --target "${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}" \
343
+ --title "Release ${INPUTS_RELEASE_TAG}" \
344
+ --notes-start-tag "${INPUTS_PREVIOUS_TAG}" \
345
+ --generate-notes \
346
+ ${{ inputs.npm-tag != 'latest' && '--prerelease' || '' }}
347
+
348
+ - name: '🧹 Clean up release branch'
349
+ working-directory: '${{ inputs.working-directory }}'
350
+ if: "${{ inputs.dry-run != 'true' && inputs.skip-branch-cleanup != 'true' }}"
351
+ continue-on-error: true
352
+ shell: 'bash'
353
+ run: |
354
+ echo "Cleaning up release branch ${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}..."
355
+ git push "https://x-access-token:${GIT_PUSH_TOKEN}@github.com/${{ github.repository }}.git" --delete "${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}"
356
+
357
+ env:
358
+ GIT_PUSH_TOKEN: '${{ inputs.github-release-token || inputs.github-token }}'
359
+ STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
.github/actions/push-docker/action.yml ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: 'Push to docker'
2
+ description: 'Builds packages and pushes a docker image to GHCR'
3
+
4
+ inputs:
5
+ github-actor:
6
+ description: 'Github actor'
7
+ required: true
8
+ github-secret:
9
+ description: 'Github secret'
10
+ required: true
11
+ ref-name:
12
+ description: 'Github ref name'
13
+ required: true
14
+ github-sha:
15
+ description: 'Github Commit SHA Hash'
16
+ required: true
17
+
18
+ runs:
19
+ using: 'composite'
20
+ steps:
21
+ - name: 'Checkout'
22
+ uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4
23
+ with:
24
+ ref: '${{ inputs.github-sha }}'
25
+ fetch-depth: 0
26
+ - name: 'Install Dependencies'
27
+ shell: 'bash'
28
+ run: 'npm install'
29
+ - name: 'Set up Docker Buildx'
30
+ uses: 'docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435' # ratchet:docker/setup-buildx-action@v3
31
+ - name: 'build'
32
+ shell: 'bash'
33
+ run: 'npm run build'
34
+ - name: 'pack @google/gemini-cli'
35
+ shell: 'bash'
36
+ run: 'npm pack -w @google/gemini-cli --pack-destination ./packages/cli/dist'
37
+ - name: 'pack @google/gemini-cli-core'
38
+ shell: 'bash'
39
+ run: 'npm pack -w @google/gemini-cli-core --pack-destination ./packages/core/dist'
40
+ - name: 'Log in to GitHub Container Registry'
41
+ uses: 'docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1' # ratchet:docker/login-action@v3
42
+ with:
43
+ registry: 'ghcr.io'
44
+ username: '${{ inputs.github-actor }}'
45
+ password: '${{ inputs.github-secret }}'
46
+ - name: 'Get branch name'
47
+ id: 'branch_name'
48
+ shell: 'bash'
49
+ run: |
50
+ REF_NAME="${INPUTS_REF_NAME}"
51
+ echo "name=${REF_NAME%/merge}" >> $GITHUB_OUTPUT
52
+ env:
53
+ INPUTS_REF_NAME: '${{ inputs.ref-name }}'
54
+ - name: 'Build and Push the Docker Image'
55
+ uses: 'docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83' # ratchet:docker/build-push-action@v6
56
+ with:
57
+ context: '.'
58
+ file: './Dockerfile'
59
+ push: true
60
+ provenance: false # avoid pushing 3 images to Aritfact Registry
61
+ tags: |
62
+ ghcr.io/${{ github.repository }}/cli:${{ steps.branch_name.outputs.name }}
63
+ ghcr.io/${{ github.repository }}/cli:${{ inputs.github-sha }}
64
+ - name: 'Create issue on failure'
65
+ if: |-
66
+ ${{ failure() }}
67
+ shell: 'bash'
68
+ env:
69
+ GITHUB_TOKEN: '${{ inputs.github-secret }}'
70
+ DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
71
+ run: |-
72
+ gh issue create \
73
+ --title "Docker build failed" \
74
+ --body "The docker build failed. See the full run for details: ${DETAILS_URL}" \
75
+ --label "release-failure"
.github/actions/push-sandbox/action.yml ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: 'Build and push sandbox docker'
2
+ description: 'Pushes sandbox docker image to container registry'
3
+
4
+ inputs:
5
+ github-actor:
6
+ description: 'Github actor'
7
+ required: true
8
+ github-secret:
9
+ description: 'Github secret'
10
+ required: true
11
+ dockerhub-username:
12
+ description: 'Dockerhub username'
13
+ required: true
14
+ dockerhub-token:
15
+ description: 'Dockerhub PAT w/ R+W'
16
+ required: true
17
+ github-sha:
18
+ description: 'Github Commit SHA Hash'
19
+ required: true
20
+ github-ref-name:
21
+ description: 'Github ref name'
22
+ required: true
23
+ dry-run:
24
+ description: 'Whether this is a dry run.'
25
+ required: true
26
+ type: 'boolean'
27
+
28
+ runs:
29
+ using: 'composite'
30
+ steps:
31
+ - name: 'Checkout'
32
+ uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
33
+ with:
34
+ ref: '${{ inputs.github-sha }}'
35
+ fetch-depth: 0
36
+ - name: 'Install Dependencies'
37
+ shell: 'bash'
38
+ run: 'npm install'
39
+ - name: 'npm build'
40
+ shell: 'bash'
41
+ run: 'npm run build'
42
+ - name: 'Set up QEMU'
43
+ uses: 'docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130' # ratchet:docker/setup-qemu-action@v3
44
+ - name: 'Set up Docker Buildx'
45
+ uses: 'docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f' # ratchet:docker/setup-buildx-action@v3
46
+ - name: 'Log in to GitHub Container Registry'
47
+ uses: 'docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9' # ratchet:docker/login-action@v3
48
+ with:
49
+ registry: 'docker.io'
50
+ username: '${{ inputs.dockerhub-username }}'
51
+ password: '${{ inputs.dockerhub-token }}'
52
+ - name: 'determine image tag'
53
+ id: 'image_tag'
54
+ shell: 'bash'
55
+ run: |-
56
+ SHELL_TAG_NAME="${INPUTS_GITHUB_REF_NAME}"
57
+ FINAL_TAG="${INPUTS_GITHUB_SHA}"
58
+ if [[ "$SHELL_TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
59
+ echo "Release detected."
60
+ FINAL_TAG="${SHELL_TAG_NAME#v}"
61
+ else
62
+ echo "Development release detected. Using commit SHA as tag."
63
+ fi
64
+ echo "Determined image tag: $FINAL_TAG"
65
+ echo "FINAL_TAG=$FINAL_TAG" >> $GITHUB_OUTPUT
66
+ env:
67
+ INPUTS_GITHUB_REF_NAME: '${{ inputs.github-ref-name }}'
68
+ INPUTS_GITHUB_SHA: '${{ inputs.github-sha }}'
69
+ # We build amd64 just so we can verify it.
70
+ # We build and push both amd64 and arm64 in the publish step.
71
+ - name: 'build'
72
+ id: 'docker_build'
73
+ shell: 'bash'
74
+ env:
75
+ GEMINI_SANDBOX_IMAGE_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}'
76
+ GEMINI_SANDBOX: 'docker'
77
+ BUILD_SANDBOX_FLAGS: '--platform linux/amd64 --load'
78
+ STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}'
79
+ run: |-
80
+ npm run build:sandbox -- \
81
+ --image "google/gemini-cli-sandbox:${STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG}" \
82
+ --output-file final_image_uri.txt
83
+ echo "uri=$(cat final_image_uri.txt)" >> $GITHUB_OUTPUT
84
+ - name: 'verify'
85
+ shell: 'bash'
86
+ run: |-
87
+ docker run --rm --entrypoint sh "${{ steps.docker_build.outputs.uri }}" -lc '
88
+ set -e
89
+ 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\"));"
90
+ /usr/local/share/npm-global/bin/gemini --version >/dev/null
91
+ '
92
+ - name: 'publish'
93
+ shell: 'bash'
94
+ if: "${{ inputs.dry-run != 'true' }}"
95
+ env:
96
+ GEMINI_SANDBOX_IMAGE_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}'
97
+ GEMINI_SANDBOX: 'docker'
98
+ BUILD_SANDBOX_FLAGS: '--platform linux/amd64,linux/arm64 --push'
99
+ STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}'
100
+ run: |-
101
+ npm run build:sandbox -- \
102
+ --image "google/gemini-cli-sandbox:${STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG}"
103
+ - name: 'Create issue on failure'
104
+ if: |-
105
+ ${{ failure() }}
106
+ shell: 'bash'
107
+ env:
108
+ GITHUB_TOKEN: '${{ inputs.github-secret }}'
109
+ DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
110
+ run: |-
111
+ gh issue create \
112
+ --title "Docker build failed" \
113
+ --body "The docker build failed. See the full run for details: ${DETAILS_URL}" \
114
+ --label "release-failure"
.github/actions/run-tests/action.yml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: 'Run Tests'
2
+ description: 'Runs the preflight checks and integration tests.'
3
+
4
+ inputs:
5
+ gemini_api_key:
6
+ description: 'The API key for running integration tests.'
7
+ required: true
8
+ working-directory:
9
+ description: 'The working directory to run the tests in.'
10
+ required: false
11
+ default: '.'
12
+
13
+ runs:
14
+ using: 'composite'
15
+ steps:
16
+ - name: 'Install system dependencies'
17
+ if: "runner.os == 'Linux'"
18
+ run: |
19
+ sudo apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq bubblewrap
20
+ # Ubuntu 24.04+ requires this to allow bwrap to function in CI
21
+ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
22
+ shell: 'bash'
23
+ - name: 'Run Tests'
24
+ env:
25
+ GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
26
+ GEMINI_CLI_TRUST_WORKSPACE: true
27
+ working-directory: '${{ inputs.working-directory }}'
28
+ run: |-
29
+ echo "::group::Build"
30
+ npm run build
31
+ echo "::endgroup::"
32
+ echo "::group::Unit Tests"
33
+ npm run test:ci
34
+ echo "::endgroup::"
35
+ echo "::group::Integration Tests (no sandbox)"
36
+ npm run test:integration:sandbox:none
37
+ echo "::endgroup::"
38
+ echo "::group::Integration Tests (docker sandbox)"
39
+ npm run test:integration:sandbox:docker
40
+ echo "::endgroup::"
41
+ shell: 'bash'
.github/actions/setup-npmrc/action.yml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: 'Setup NPMRC'
2
+ description: 'Sets up NPMRC with all the correct repos for readonly access.'
3
+
4
+ inputs:
5
+ github-token:
6
+ description: 'the github token'
7
+ required: true
8
+
9
+ outputs:
10
+ auth-token:
11
+ description: 'The generated NPM auth token'
12
+ value: '${{ steps.npm_auth_token.outputs.auth-token }}'
13
+
14
+ runs:
15
+ using: 'composite'
16
+ steps:
17
+ - name: 'Configure .npmrc'
18
+ shell: 'bash'
19
+ run: |-
20
+ echo ""@google-gemini:registry=https://npm.pkg.github.com"" > ~/.npmrc
21
+ echo ""//npm.pkg.github.com/:_authToken=${INPUTS_GITHUB_TOKEN}"" >> ~/.npmrc
22
+ echo ""@google:registry=https://wombat-dressing-room.appspot.com"" >> ~/.npmrc
23
+ env:
24
+ INPUTS_GITHUB_TOKEN: '${{ inputs.github-token }}'
.github/actions/tag-npm-release/action.yml ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: 'Tag an NPM release'
2
+ description: 'Tags a specific npm version to a specific channel.'
3
+
4
+ inputs:
5
+ channel:
6
+ description: 'NPM Channel tag'
7
+ required: true
8
+ version:
9
+ description: 'version'
10
+ required: true
11
+ dry-run:
12
+ description: 'Whether to run in dry-run mode.'
13
+ required: true
14
+ github-token:
15
+ description: 'The GitHub token for creating the release.'
16
+ required: true
17
+ wombat-token-core:
18
+ description: 'The npm token for the wombat @google/gemini-cli-core'
19
+ required: true
20
+ wombat-token-cli:
21
+ description: 'The npm token for wombat @google/gemini-cli'
22
+ required: true
23
+ wombat-token-a2a-server:
24
+ description: 'The npm token for the @google/gemini-cli-a2a-server package.'
25
+ required: true
26
+ cli-package-name:
27
+ description: 'The name of the cli package.'
28
+ required: true
29
+ core-package-name:
30
+ description: 'The name of the core package.'
31
+ required: true
32
+ a2a-package-name:
33
+ description: 'The name of the a2a package.'
34
+ required: true
35
+ working-directory:
36
+ description: 'The working directory to run the commands in.'
37
+ required: false
38
+ default: '.'
39
+
40
+ runs:
41
+ using: 'composite'
42
+ steps:
43
+ - name: 'Setup Node.js'
44
+ uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020'
45
+ with:
46
+ node-version-file: '${{ inputs.working-directory }}/.nvmrc'
47
+
48
+ - name: 'configure .npmrc'
49
+ uses: './.github/actions/setup-npmrc'
50
+ with:
51
+ github-token: '${{ inputs.github-token }}'
52
+
53
+ - name: 'Get core Token'
54
+ uses: './.github/actions/npm-auth-token'
55
+ id: 'core-token'
56
+ with:
57
+ package-name: '${{ inputs.core-package-name }}'
58
+ github-token: '${{ inputs.github-token }}'
59
+ wombat-token-core: '${{ inputs.wombat-token-core }}'
60
+ wombat-token-cli: '${{ inputs.wombat-token-cli }}'
61
+ wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}'
62
+
63
+ - name: 'Change tag for CORE'
64
+ if: |-
65
+ ${{ inputs.dry-run != 'true' }}
66
+ env:
67
+ NODE_AUTH_TOKEN: '${{ steps.core-token.outputs.auth-token }}'
68
+ INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}'
69
+ INPUTS_VERSION: '${{ inputs.version }}'
70
+ INPUTS_CHANNEL: '${{ inputs.channel }}'
71
+ shell: 'bash'
72
+ working-directory: '${{ inputs.working-directory }}'
73
+ run: |
74
+ npm dist-tag add ${INPUTS_CORE_PACKAGE_NAME}@${INPUTS_VERSION} ${INPUTS_CHANNEL}
75
+
76
+ - name: 'Get cli Token'
77
+ uses: './.github/actions/npm-auth-token'
78
+ id: 'cli-token'
79
+ with:
80
+ package-name: '${{ inputs.cli-package-name }}'
81
+ github-token: '${{ inputs.github-token }}'
82
+ wombat-token-core: '${{ inputs.wombat-token-core }}'
83
+ wombat-token-cli: '${{ inputs.wombat-token-cli }}'
84
+ wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}'
85
+
86
+ - name: 'Change tag for CLI'
87
+ if: |-
88
+ ${{ inputs.dry-run != 'true' }}
89
+ env:
90
+ NODE_AUTH_TOKEN: '${{ steps.cli-token.outputs.auth-token }}'
91
+ INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}'
92
+ INPUTS_VERSION: '${{ inputs.version }}'
93
+ INPUTS_CHANNEL: '${{ inputs.channel }}'
94
+ shell: 'bash'
95
+ working-directory: '${{ inputs.working-directory }}'
96
+ run: |
97
+ npm dist-tag add ${INPUTS_CLI_PACKAGE_NAME}@${INPUTS_VERSION} ${INPUTS_CHANNEL}
98
+
99
+ - name: 'Get a2a Token'
100
+ uses: './.github/actions/npm-auth-token'
101
+ id: 'a2a-token'
102
+ with:
103
+ package-name: '${{ inputs.a2a-package-name }}'
104
+ github-token: '${{ inputs.github-token }}'
105
+ wombat-token-core: '${{ inputs.wombat-token-core }}'
106
+ wombat-token-cli: '${{ inputs.wombat-token-cli }}'
107
+ wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}'
108
+
109
+ - name: 'Change tag for a2a'
110
+ if: |-
111
+ ${{ inputs.dry-run == 'false' }}
112
+ env:
113
+ NODE_AUTH_TOKEN: '${{ steps.a2a-token.outputs.auth-token }}'
114
+ INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}'
115
+ INPUTS_VERSION: '${{ inputs.version }}'
116
+ INPUTS_CHANNEL: '${{ inputs.channel }}'
117
+ shell: 'bash'
118
+ working-directory: '${{ inputs.working-directory }}'
119
+ run: |
120
+ npm dist-tag add ${INPUTS_A2A_PACKAGE_NAME}@${INPUTS_VERSION} ${INPUTS_CHANNEL}
121
+
122
+ - name: 'Log dry run'
123
+ if: |-
124
+ ${{ inputs.dry-run == 'true' }}
125
+ shell: 'bash'
126
+ working-directory: '${{ inputs.working-directory }}'
127
+ run: |
128
+ 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}."
129
+
130
+ env:
131
+ INPUTS_CHANNEL: '${{ inputs.channel }}'
132
+
133
+ INPUTS_VERSION: '${{ inputs.version }}'
134
+
135
+ INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}'
136
+
137
+ INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}'
138
+
139
+ INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}'
.github/actions/verify-release/action.yml ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: 'Verify an NPM release'
2
+ description: 'Fetches a package from NPM and does some basic smoke tests'
3
+
4
+ inputs:
5
+ npm-package:
6
+ description: 'NPM Package'
7
+ required: true
8
+ default: '@google/gemini-cli@latest'
9
+ npm-registry-url:
10
+ description: 'NPM Registry URL'
11
+ required: true
12
+ npm-registry-scope:
13
+ description: 'NPM Registry Scope'
14
+ required: true
15
+ expected-version:
16
+ description: 'Expected version'
17
+ required: true
18
+ gemini_api_key:
19
+ description: 'The API key for running integration tests.'
20
+ required: true
21
+ github-token:
22
+ description: 'The GitHub token for running integration tests.'
23
+ required: true
24
+ working-directory:
25
+ description: 'The working directory to run the tests in.'
26
+ required: false
27
+ default: '.'
28
+
29
+ runs:
30
+ using: 'composite'
31
+ steps:
32
+ - name: 'setup node'
33
+ uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
34
+ with:
35
+ node-version: '20'
36
+
37
+ - name: 'configure .npmrc'
38
+ uses: './.github/actions/setup-npmrc'
39
+ with:
40
+ github-token: '${{ inputs.github-token }}'
41
+
42
+ - name: 'Clear npm cache'
43
+ shell: 'bash'
44
+ run: 'npm cache clean --force'
45
+
46
+ - name: 'Install from NPM'
47
+ uses: 'nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08' # ratchet:nick-fields/retry@v3
48
+ with:
49
+ timeout_seconds: 900
50
+ retry_wait_seconds: 30
51
+ max_attempts: 10
52
+ command: |-
53
+ cd ${{ inputs.working-directory }}
54
+ npm install --prefer-online --no-cache -g "${{ inputs.npm-package }}"
55
+
56
+ - name: 'Smoke test - NPM Install'
57
+ shell: 'bash'
58
+ working-directory: '${{ inputs.working-directory }}'
59
+ run: |-
60
+ gemini_version=$(gemini --version)
61
+ if [ "$gemini_version" != "${INPUTS_EXPECTED_VERSION}" ]; then
62
+ echo "❌ NPM Version mismatch: Got $gemini_version from ${INPUTS_NPM_PACKAGE}, expected ${INPUTS_EXPECTED_VERSION}"
63
+ exit 1
64
+ fi
65
+ env:
66
+ INPUTS_EXPECTED_VERSION: '${{ inputs.expected-version }}'
67
+ INPUTS_NPM_PACKAGE: '${{ inputs.npm-package }}'
68
+
69
+ - name: 'Clear npm cache'
70
+ shell: 'bash'
71
+ run: 'npm cache clean --force'
72
+
73
+ - name: 'Smoke test - NPX Run'
74
+ shell: 'bash'
75
+ working-directory: '${{ inputs.working-directory }}'
76
+ run: |-
77
+ gemini_version=$(npx --yes --prefer-online "${INPUTS_NPM_PACKAGE}" --version)
78
+ if [ "$gemini_version" != "${INPUTS_EXPECTED_VERSION}" ]; then
79
+ echo "❌ NPX Run Version mismatch: Got $gemini_version from ${INPUTS_NPM_PACKAGE}, expected ${INPUTS_EXPECTED_VERSION}"
80
+ exit 1
81
+ fi
82
+ env:
83
+ INPUTS_NPM_PACKAGE: '${{ inputs.npm-package }}'
84
+ INPUTS_EXPECTED_VERSION: '${{ inputs.expected-version }}'
85
+
86
+ - name: 'Install dependencies for integration tests'
87
+ shell: 'bash'
88
+ working-directory: '${{ inputs.working-directory }}'
89
+ run: 'npm ci --ignore-scripts'
90
+
91
+ - name: '🔬 Run integration tests against NPM release'
92
+ working-directory: '${{ inputs.working-directory }}'
93
+ env:
94
+ GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
95
+ GEMINI_CLI_TRUST_WORKSPACE: true
96
+ INTEGRATION_TEST_USE_INSTALLED_GEMINI: 'true'
97
+ # We must diable CI mode here because it interferes with interactive tests.
98
+ # See https://github.com/google-gemini/gemini-cli/issues/10517
99
+ CI: 'false'
100
+ shell: 'bash'
101
+ run: |
102
+ export INTEGRATION_TEST_GEMINI_BINARY_PATH=$(which gemini)
103
+ npm run test:integration:sandbox:none
packages/a2a-server/GEMINI.md ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Gemini CLI A2A Server (`@google/gemini-cli-a2a-server`)
2
+
3
+ Experimental Agent-to-Agent (A2A) server that exposes Gemini CLI capabilities
4
+ over HTTP for inter-agent communication.
5
+
6
+ ## Architecture
7
+
8
+ - `src/agent/`: Agent session management for A2A interactions.
9
+ - `src/commands/`: CLI command definitions for the A2A server binary.
10
+ - `src/config/`: Server configuration.
11
+ - `src/http/`: HTTP server and route handlers.
12
+ - `src/persistence/`: Session and state persistence.
13
+ - `src/utils/`: Shared utility functions.
14
+ - `src/types.ts`: Shared type definitions.
15
+
16
+ ## Running
17
+
18
+ - Binary entry point: `gemini-cli-a2a-server`
19
+
20
+ ## Testing
21
+
22
+ - Run tests: `npm test -w @google/gemini-cli-a2a-server`
packages/a2a-server/README.md ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # Gemini CLI A2A Server
2
+
3
+ ## All code in this package is experimental and under active development
4
+
5
+ This package contains the A2A server implementation for the Gemini CLI.
packages/a2a-server/development-extension-rfc.md ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # RFC: Gemini CLI A2A Development-Tool Extension
2
+
3
+ ## 1. Introduction
4
+
5
+ ### 1.1 Overview
6
+
7
+ To standardize client integrations with the Gemini CLI agent, this document
8
+ proposes the `development-tool` extension for the A2A protocol.
9
+
10
+ Rather than creating a new protocol, this specification builds upon the existing
11
+ A2A protocol. As an open-source standard recently adopted by the Linux
12
+ Foundation, A2A provides a robust foundation for core concepts like tasks,
13
+ messages, and streaming events. This extension-based approach allows us to
14
+ leverage A2A's proven architecture while defining the specific capabilities
15
+ required for rich, interactive workflows with the Gemini CLI agent.
16
+
17
+ ### 1.2 Motivation
18
+
19
+ Recent work integrating Gemini CLI with clients like Zed and Gemini Code
20
+ Assist’s agent mode has highlighted the need for a robust, standard
21
+ communication protocol. Standardizing on A2A provides several key advantages:
22
+
23
+ - **Solid Foundation**: Provides a robust, open standard that ensures a stable,
24
+ predictable, and consistent integration experience across different IDEs and
25
+ client surfaces.
26
+ - **Extensibility**: Creates a flexible foundation to support new tools and
27
+ workflows as they emerge.
28
+ - **Ecosystem Alignment**: Aligns Gemini CLI with a growing industry standard,
29
+ fostering broader interoperability.
30
+
31
+ ## 2. Communication Flow
32
+
33
+ The interaction follows A2A’s task-based, streaming pattern. The client sends a
34
+ `message/stream` request and the agent responds with a `contextId` / `taskId`
35
+ and a stream of events. `TaskStatusUpdateEvent` events are used to convey the
36
+ overall state of the task. The task is complete when the agent sends a final
37
+ `TaskStatusUpdateEvent` with `final: true` and a terminal status like
38
+ `completed` or `failed`.
39
+
40
+ ### 2.1 Asynchronous Responses and Notifications
41
+
42
+ Clients that may disconnect from the agent should supply a
43
+ `PushNotificationConfig` to the agent with the initial `message/stream` method
44
+ or subsequently with the `tasks/pushNotificationConfig/set` method so that the
45
+ agent can call back when updates are ready.
46
+
47
+ ## 3. The `development-tool` extension
48
+
49
+ ### 3.1 Overview
50
+
51
+ The `development-tool` extension establishes a communication contract for
52
+ workflows between a client and the Gemini CLI agent. It consists of a
53
+ specialized set of schemas, embedded within core A2A data structures, that
54
+ enable the agent to stream real-time updates on its state and thought process.
55
+ These schemas also provide the mechanism for the agent to request user
56
+ permission before executing tools.
57
+
58
+ **Sample Agent Card**
59
+
60
+ ```json
61
+ {
62
+ "name": "Gemini CLI Agent",
63
+ "description": "An agent that generates code based on natural language instructions.",
64
+ "capabilities": {
65
+ "streaming": true,
66
+ "extensions": [
67
+ {
68
+ "uri": "https://github.com/google-gemini/gemini-cli/blob/main/docs/a2a/developer-profile/v0/spec.md",
69
+ "description": "An extension for interactive development tasks, enabling features like code generation, tool usage, and real-time status updates.",
70
+ "required": true
71
+ }
72
+ ]
73
+ }
74
+ }
75
+ ```
76
+
77
+ **Versioning**
78
+
79
+ The agent card `uri` field contains an embedded semantic version. The client
80
+ must extract this version to determine compatibility with the agent extension
81
+ using the compatibility logic defined in Semantic Versioning 2.0.0 spec.
82
+
83
+ ### 3.2 Schema Definitions
84
+
85
+ This section defines the schemas for the `development-tool` A2A extension,
86
+ organized by their function within the communication flow. Note that all custom
87
+ objects included in the `metadata` field (e.g. `Message.metadata`) must be keyed
88
+ by the unique URI that points to that extension’s spec to prevent naming
89
+ collisions with other extensions.
90
+
91
+ **Initialization & Configuration**
92
+
93
+ The first message in a session must contain an `AgentSettings` object in its
94
+ metadata. This object provides the agent with the necessary configuration
95
+ information for proper initialization. Additional configuration settings (ex.
96
+ MCP servers, allowed tools, etc.) can be added to this message.
97
+
98
+ **Schema**
99
+
100
+ ```proto
101
+ syntax = "proto3";
102
+
103
+ // Configuration settings for the Gemini CLI agent.
104
+ message AgentSettings {
105
+ // The absolute path to the workspace directory where the agent will execute.
106
+ string workspace_path = 1;
107
+ }
108
+ ```
109
+
110
+ **Agent-to-Client Messages**
111
+
112
+ All real-time updates from the agent (including its thoughts, tool calls, and
113
+ simple text replies) are streamed to the client as `TaskStatusUpdateEvents`.
114
+
115
+ Each Event contains a `Message` object, which holds the content in one of two
116
+ formats:
117
+
118
+ - **TextPart**: Used for standard text messages. This part requires no custom
119
+ schema.
120
+ - **DataPart**: Used for complex, structured objects. Tool Calls and Thoughts
121
+ are sent this way, each using their respective schemas defined below.
122
+
123
+ **Tool Calls**
124
+
125
+ The `ToolCall` schema is designed to provide a structured representation of a
126
+ tool’s execution lifecycle. This protocol defines a clear state machine and
127
+ provides detailed schemas for common development tasks (file edits, shell
128
+ commands, MCP Tool), ensuring clients can build reliable UIs without being tied
129
+ to a specific agent implementation.
130
+
131
+ The core principle is that the agent sends a `ToolCall` object on every update.
132
+ This makes client-side logic stateless and simple.
133
+
134
+ **Tool Call Lifecycle**
135
+
136
+ 1. **Creation**: The agent sends a `ToolCall` object with `status: PENDING`. If
137
+ user permission is required, the `confirmation_request` field will be
138
+ populated.
139
+ 2. **Confirmation**: If the client needs to confirm the message, the client
140
+ will send a `ToolCallConfirmation`. If the client responds with a
141
+ cancellation, execution will be skipped.
142
+ 3. **Execution**: Once approved (or if no approval is required), the agent
143
+ sends an update with `status: EXECUTING`. It can stream real-time progress
144
+ by updating the `live_content` field.
145
+ 4. **Completion**: The agent sends a final update with the status set to
146
+ `SUCCEEDED`, `FAILED`, or `CANCELLED` and populates the appropriate result
147
+ field.
148
+
149
+ **Schema**
150
+
151
+ ```proto
152
+ syntax = "proto3";
153
+
154
+ import "google/protobuf/struct.proto";
155
+
156
+ // ToolCall is the central message representing a tool's execution lifecycle.
157
+ // The entire object is sent from the agent to client on every update.
158
+ message ToolCall {
159
+ // A unique identifier, assigned by the agent
160
+ string tool_call_id = 1;
161
+
162
+ // The current state of the tool call in its lifecycle
163
+ ToolCallStatus status = 2;
164
+
165
+ // Name of the tool being called (e.g. 'Edit', 'ShellTool')
166
+ string tool_name = 3;
167
+
168
+ // An optional description of the tool call's purpose to show the user
169
+ optional string description = 4;
170
+
171
+ // The structured input params provided by the LLM for tool invocation.
172
+ google.protobuf.Struct input_parameters = 5;
173
+
174
+ // String containing the real-time output from the tool as it executes (primarily designed for shell output).
175
+ // During streaming the entire string is replaced on each update
176
+ optional string live_content = 6;
177
+
178
+ // The final result of the tool (used to replace live_content when applicable)
179
+ oneof result {
180
+ // The output on tool success
181
+ ToolOutput output = 7;
182
+ // The error details if the tool failed
183
+ ErrorDetails error = 8;
184
+ }
185
+
186
+ // If the tool requires user confirmation, this field will be populated while status is PENDING
187
+ optional ConfirmationRequest confirmation_request = 9;
188
+ }
189
+
190
+ // Possible execution status of a ToolCall
191
+ enum ToolCallStatus {
192
+ STATUS_UNSPECIFIED = 0;
193
+ PENDING = 1;
194
+ EXECUTING = 2;
195
+ SUCCEEDED = 3;
196
+ FAILED = 4;
197
+ CANCELLED = 5;
198
+ }
199
+
200
+ // ToolOutput represents the final, successful, output of a tool
201
+ message ToolOutput {
202
+ oneof result {
203
+ string text = 1;
204
+ // For ToolCalls which resulted in a file modification
205
+ FileDiff diff = 2;
206
+ // A generic fallback for any other structured JSON data
207
+ google.protobuf.Struct structured_data = 3;
208
+ }
209
+ }
210
+
211
+ // A structured representation of an error
212
+ message ErrorDetails {
213
+ // User facing error message
214
+ string message = 1;
215
+ // Optional agent-specific error type or category (e.g. read_content_failure, grep_execution_error, mcp_tool_error)
216
+ optional string type = 2;
217
+ // Optional status code
218
+ optional int32 status_code = 3;
219
+ }
220
+
221
+ // ConfirmationRequest is sent from the agent to client to request user permission for a ToolCall
222
+ message ConfirmationRequest {
223
+ // A list of choices for the user to select from
224
+ repeated ConfirmationOption options = 1;
225
+ // Specific details of the action requiring user confirmation
226
+ oneof details {
227
+ ExecuteDetails execute_details = 2;
228
+ FileDiff file_edit_details = 3;
229
+ McpDetails mcp_details = 4;
230
+ GenericDetails generic_details = 5;
231
+ }
232
+ }
233
+
234
+ // A single choice presented to the user during a confirmation request
235
+ message ConfirmationOption {
236
+ // Unique ID for the choice (e.g. proceed_once, cancel)
237
+ string id = 1;
238
+ // Human-readable choice (e.g. Allow Once, Reject).
239
+ string name = 2;
240
+ // An optional longer description for a tooltip
241
+ optional string description = 3;
242
+ }
243
+
244
+ // Details for a request to execute a shell command
245
+ message ExecuteDetails {
246
+ // The shell command to be executed
247
+ string command = 1;
248
+ // An optional directory in which the command will be run
249
+ optional string working_directory = 2;
250
+ }
251
+
252
+
253
+ message FileDiff {
254
+ string file_name = 1;
255
+ // The absolute path to the file to modify
256
+ string file_path = 2;
257
+ // The original content, if the file exists
258
+ optional string old_content = 3;
259
+ string new_content = 4;
260
+ // Pre-formatted diff string for display
261
+ optional string formatted_diff = 5;
262
+ }
263
+
264
+ // Details for an MCP (Model Context Protocol) tool confirmation
265
+ message McpDetails {
266
+ // The name of the MCP server that provides the tool
267
+ string server_name = 1;
268
+ // THe name of the tool being called from the MCP Server
269
+ string tool_name = 2;
270
+ }
271
+
272
+ // Generic catch-all for ToolCall requests that don't fit other types
273
+ message GenericDetails {
274
+ // Description of the action requiring confirmation
275
+ string description = 1;
276
+ }
277
+ ```
278
+
279
+ **Agent Thoughts**
280
+
281
+ **Schema**
282
+
283
+ ```proto
284
+ syntax = "proto3";
285
+
286
+ // Represents a thought with a subject and a detailed description.
287
+ message AgentThought {
288
+ // A concise subject line or title for the thought.
289
+ string subject = 1;
290
+
291
+ // The description or elaboration of the thought itself.
292
+ string description = 2;
293
+ }
294
+ ```
295
+
296
+ **Event Metadata**
297
+
298
+ The `metadata` object in `TaskStatusUpdateEvent` is used by the A2A client to
299
+ deserialize the `TaskStatusUpdateEvents` into their appropriate objects.
300
+
301
+ **Schema**
302
+
303
+ ```proto
304
+ syntax = "proto3";
305
+
306
+ // A DevelopmentToolEvent event.
307
+ message DevelopmentToolEvent {
308
+ // Enum representing the specific type of development tool event.
309
+ enum DevelopmentToolEventKind {
310
+ // The default, unspecified value.
311
+ DEVELOPMENT_TOOL_EVENT_KIND_UNSPECIFIED = 0;
312
+ TOOL_CALL_CONFIRMATION = 1;
313
+ TOOL_CALL_UPDATE = 2;
314
+ TEXT_CONTENT = 3;
315
+ STATE_CHANGE = 4;
316
+ THOUGHT = 5;
317
+ }
318
+
319
+ // The specific kind of event that occurred.
320
+ DevelopmentToolEventKind kind = 1;
321
+
322
+ // The model used for this event.
323
+ string model = 2;
324
+
325
+ // The tier of the user (optional).
326
+ string user_tier = 3;
327
+
328
+ // An unexpected error occurred in the agent execution (optional).
329
+ string error = 4;
330
+ }
331
+ ```
332
+
333
+ **Client-to-Agent Messages**
334
+
335
+ When the agent sends a `TaskStatusUpdateEvent` with `status.state` set to
336
+ `input-required` and its message contains a `ConfirmationRequest`, the client
337
+ must respond by sending a new `message/stream` request.
338
+
339
+ This new request must include the `contextId` and the `taskId` from the ongoing
340
+ task and contain a `ToolCallConfirmation` object. This object conveys the user's
341
+ decision regarding the tool call that was awaiting approval.
342
+
343
+ **Schema**
344
+
345
+ ```proto
346
+ syntax = "proto3";
347
+
348
+ // The client's response to a ConfirmationRequest.
349
+ message ToolCallConfirmation {
350
+ // A unique identifier, assigned by the agent
351
+ string tool_call_id = 1;
352
+ // The 'id' of the ConfirmationOption chosen by the user.
353
+ string selected_option_id = 2;
354
+ // Included if the user modifies the proposed change.
355
+ // The type should correspond to the original ConfirmationRequest details.
356
+ oneof modified_details {
357
+ // Corresponds to a FileDiff confirmation
358
+ ModifiedFileDetails file_details = 3;
359
+ }
360
+ }
361
+
362
+ message ModifiedFileDetails {
363
+ // The new content after user edits.
364
+ string new_content = 1;
365
+ }
366
+ ```
367
+
368
+ ### 3.3 Method Definitions
369
+
370
+ This section defines the new methods introduced by the `development-tool`
371
+ extension.
372
+
373
+ **Method: `commands/get`**
374
+
375
+ This method allows the client to discover slash commands supported by Gemini
376
+ CLI. The client should call this method during startup to dynamically populate
377
+ its command list.
378
+
379
+ ```proto
380
+ // Response message containing the list of all top-level slash commands.
381
+ message GetAllSlashCommandsResponse {
382
+ // A list of the top-level slash commands.
383
+ repeated SlashCommand commands = 1;
384
+ }
385
+
386
+ // Represents a single slash command, which can contain subcommands.
387
+ message SlashCommand {
388
+ // The primary name of the command.
389
+ string name = 1;
390
+ // A detailed description of what the command does.
391
+ string description = 2;
392
+ // A list of arguments that the command accepts.
393
+ repeated SlashCommandArgument arguments = 3;
394
+ // A list of nested subcommands.
395
+ repeated SlashCommand sub_commands = 4;
396
+ }
397
+
398
+ // Defines the structure for a single slash command argument.
399
+ message SlashCommandArgument {
400
+ // The name of the argument.
401
+ string name = 1;
402
+ // A brief description of what the argument is for.
403
+ string description = 2;
404
+ // Whether the argument is required or optional.
405
+ bool is_required = 3;
406
+ }
407
+ ```
408
+
409
+ **Method: `command/execute`**
410
+
411
+ This method allows the client to execute a slash command. Following the initial
412
+ `ExecuteSlashCommandResponse`, the agent will use the standard streaming
413
+ mechanism to communicate the command's progress and output. All subsequent
414
+ updates, including textual output, agent thoughts, and any required user
415
+ confirmations for tool calls (like executing a shell command), will be sent as
416
+ `TaskStatusUpdateEvent` messages, re-using the schemas defined above.
417
+
418
+ ```proto
419
+ // Request to execute a specific slash command.
420
+ message ExecuteSlashCommandRequest {
421
+ // The path to the command, e.g., ["memory", "list"] for /memory list
422
+ repeated string command_path = 1;
423
+ // The arguments for the command as a single string.
424
+ string args = 2;
425
+ }
426
+
427
+ // Enum for the initial status of a command execution request.
428
+ enum CommandExecutionStatus {
429
+ // Default unspecified status.
430
+ COMMAND_EXECUTION_STATUS_UNSPECIFIED = 0;
431
+ // The command was successfully received and its execution has started.
432
+ STARTED = 1;
433
+ // The command failed to start (e.g., command not found, invalid format).
434
+ FAILED_TO_START = 2;
435
+ // The command has been paused and is waiting for the user to confirm
436
+ // a set of shell commands.
437
+ AWAITING_SHELL_CONFIRMATION = 3;
438
+ // The command has been paused and is waiting for the user to confirm
439
+ // a specific action.
440
+ AWAITING_ACTION_CONFIRMATION = 4;
441
+ }
442
+
443
+ // The immediate, async response after requesting a command execution.
444
+ message ExecuteSlashCommandResponse {
445
+ // A unique taskID for this specific command execution.
446
+ string execution_id = 1;
447
+ // The initial status of the command execution.
448
+ CommandExecutionStatus status = 2;
449
+ // An optional message, particularly useful for explaining why a command
450
+ // failed to start.
451
+ string message = 3;
452
+ }
453
+ ```
454
+
455
+ ## 4. Separation of Concerns
456
+
457
+ We believe that all client-side context (ex., workspace state) and client-side
458
+ tool execution (ex. read active buffers) should be routed through MCP.
459
+
460
+ This approach enforces a strict separation of concerns: the A2A
461
+ `development-tool` extension standardizes communication to the agent, while MCP
462
+ serves as the single, authoritative interface for client-side capabilities.
463
+
464
+ ## Appendix
465
+
466
+ ### A. Example Interaction Flow
467
+
468
+ 1. **Client -> Server**: The client sends a `message/stream` request containing
469
+ the initial prompt and configuration in an `AgentSettings` object.
470
+ 2. **Server -> Client**: SSE stream begins.
471
+ - **Event 1**: The server sends a `Task` object with
472
+ `status.state: 'submitted'` and the new `taskId`.
473
+ - **Event 2**: The server sends a `TaskStatusUpdateEvent` with the metadata
474
+ `kind` set to `'STATE_CHANGE'` and `status.state` set to `'working'`.
475
+ 3. **Agent Logic**: The agent processes the prompt and decides to call the
476
+ `write_file` tool, which requires user confirmation.
477
+ 4. **Server -> Client**:
478
+ - **Event 3**: The server sends a `TaskStatusUpdateEvent`. The metadata
479
+ `kind` is `'TOOL_CALL_UPDATE'`, and the `DataPart` contains a `ToolCall`
480
+ object with its `status` as `'PENDING'` and a populated
481
+ `confirmation_request`.
482
+ - **Event 4**: The server sends a final `TaskStatusUpdateEvent` for this
483
+ exchange. The metadata `kind` is `'STATE_CHANGE'`, the `status.state` is
484
+ `'input-required'`, and `final` is `true`. The stream for this request
485
+ ends.
486
+ 5. **Client**: The client UI renders the confirmation prompt based on the
487
+ `ToolCall` object from Event 3. The user clicks "Approve."
488
+ 6. **Client -> Server**: The client sends a new `message/stream` request. It
489
+ includes the `taskId` from the ongoing task and a `DataPart` containing a
490
+ `ToolCallConfirmation` object (e.g.,
491
+ `{"tool_call_id": "...", "selected_option_id": "proceed_once"}`).
492
+ 7. **Server -> Client**: A new SSE stream begins for the second request.
493
+ - **Event 1**: The server sends a `TaskStatusUpdateEvent` with
494
+ `kind: 'TOOL_CALL_UPDATE'`, containing the `ToolCall` object with its
495
+ `status` now set to `'EXECUTING'`.
496
+ - **Event 2**: After the tool runs, the server sends another
497
+ `TaskStatusUpdateEvent` with `kind: 'TOOL_CALL_UPDATE'`, containing the
498
+ `ToolCall` with its `status` as `'SUCCEEDED'`.
499
+ 8. **Agent Logic**: The agent receives the successful tool result and generates
500
+ a final textual response.
501
+ 9. **Server -> Client**:
502
+ - **Event 3**: The server sends a `TaskStatusUpdateEvent` with
503
+ `kind: 'TEXT_CONTENT'` and a `TextPart` containing the agent's final
504
+ answer.
505
+ - **Event 4**: The server sends the final `TaskStatusUpdateEvent`. The
506
+ `kind` is `'STATE_CHANGE'`, the `status.state` is `'completed'`, and
507
+ `final` is `true`. The stream ends.
508
+ 10. **Client**: The client displays the final answer. The task is now complete
509
+ but can be continued by sending another message with the same `taskId`.
packages/a2a-server/index.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ export * from './src/index.js';
packages/a2a-server/package.json ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@google/gemini-cli-a2a-server",
3
+ "version": "0.62.0-nightly.20260915.gae28844fb",
4
+ "description": "Gemini CLI A2A Server",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/google-gemini/gemini-cli.git",
8
+ "directory": "packages/a2a-server"
9
+ },
10
+ "type": "module",
11
+ "main": "dist/index.js",
12
+ "bin": {
13
+ "gemini-cli-a2a-server": "dist/a2a-server.mjs"
14
+ },
15
+ "scripts": {
16
+ "build": "node ../../scripts/build_package.js",
17
+ "start": "node dist/src/http/server.js",
18
+ "lint": "eslint . --ext .ts,.tsx",
19
+ "format": "prettier --write .",
20
+ "test": "vitest run",
21
+ "test:ci": "vitest run --coverage",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "dependencies": {
28
+ "@a2a-js/sdk": "0.3.11",
29
+ "@google-cloud/storage": "7.19.0",
30
+ "@google/gemini-cli-core": "file:../core",
31
+ "express": "5.1.0",
32
+ "fs-extra": "11.3.0",
33
+ "strip-json-comments": "3.1.1",
34
+ "tar": "7.5.8",
35
+ "uuid": "13.0.0",
36
+ "winston": "3.17.0"
37
+ },
38
+ "devDependencies": {
39
+ "@google/genai": "1.30.0",
40
+ "@types/express": "5.0.3",
41
+ "@types/fs-extra": "11.0.4",
42
+ "@types/supertest": "6.0.3",
43
+ "@types/tar": "6.1.13",
44
+ "dotenv": "16.4.5",
45
+ "supertest": "7.1.4",
46
+ "typescript": "5.8.3",
47
+ "vitest": "3.2.4"
48
+ },
49
+ "engines": {
50
+ "node": ">=20"
51
+ }
52
+ }
packages/a2a-server/src/agent/executor.test.ts ADDED
@@ -0,0 +1,587 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
8
+ import { CoderAgentExecutor } from './executor.js';
9
+ import type {
10
+ ExecutionEventBus,
11
+ RequestContext,
12
+ TaskStore,
13
+ } from '@a2a-js/sdk/server';
14
+ import { EventEmitter } from 'node:events';
15
+ import { requestStorage } from '../http/requestStorage.js';
16
+
17
+ vi.mock('../utils/path_utils.js', () => ({
18
+ validateWorkspacePath: vi
19
+ .fn()
20
+ .mockImplementation(async (path?: string) => path || process.cwd()),
21
+ }));
22
+
23
+ // Mocks for constructor dependencies
24
+ vi.mock('@google/gemini-cli-core', () => ({
25
+ GeminiEventType: {
26
+ PRIMARY_TURN_STARTED: 'PRIMARY_TURN_STARTED',
27
+ SECONDARY_TURN_STARTED: 'SECONDARY_TURN_STARTED',
28
+ },
29
+ SimpleExtensionLoader: vi.fn(),
30
+ checkPathTrust: vi.fn().mockReturnValue({ isTrusted: false }),
31
+ isHeadlessMode: vi.fn().mockReturnValue(true),
32
+ resolveToRealPath: vi.fn().mockImplementation((p) => p),
33
+ }));
34
+
35
+ vi.mock('../config/config.js', () => ({
36
+ loadConfig: vi.fn().mockReturnValue({
37
+ getSessionId: () => 'test-session',
38
+ getTargetDir: () => '/tmp',
39
+ getCheckpointingEnabled: () => false,
40
+ }),
41
+ loadEnvironment: vi.fn(),
42
+ setIsTrusted: vi.fn().mockReturnValue(false),
43
+ setTargetDir: vi.fn().mockReturnValue('/tmp'),
44
+ envStorage: {
45
+ run: (env: Record<string, string>, cb: () => unknown) => cb(),
46
+ },
47
+ cwdSymbol: Symbol('cwd'),
48
+ }));
49
+
50
+ vi.mock('../config/settings.js', () => ({
51
+ loadSettings: vi.fn().mockReturnValue({}),
52
+ }));
53
+
54
+ vi.mock('../config/extension.js', () => ({
55
+ loadExtensions: vi.fn().mockReturnValue([]),
56
+ }));
57
+
58
+ vi.mock('../http/requestStorage.js', () => ({
59
+ requestStorage: {
60
+ getStore: vi.fn(),
61
+ },
62
+ }));
63
+
64
+ vi.mock('./task.js', () => {
65
+ const mockTaskInstance = (taskId: string, contextId: string) => ({
66
+ id: taskId,
67
+ contextId,
68
+ taskState: 'working',
69
+ acceptUserMessage: vi
70
+ .fn()
71
+ .mockImplementation(async function* (context, aborted) {
72
+ const isConfirmation = (
73
+ context.userMessage.parts as Array<{ kind: string }>
74
+ ).some((p) => p.kind === 'confirmation');
75
+ // Hang only for main user messages (text), allow confirmations to finish quickly
76
+ if (!isConfirmation && aborted) {
77
+ await new Promise((resolve) => {
78
+ aborted.addEventListener('abort', resolve, { once: true });
79
+ });
80
+ }
81
+ yield { type: 'content', value: 'hello' };
82
+ }),
83
+ acceptAgentMessage: vi.fn().mockResolvedValue(undefined),
84
+ scheduleToolCalls: vi.fn().mockResolvedValue(undefined),
85
+ waitForPendingTools: vi.fn().mockResolvedValue(undefined),
86
+ getAndClearCompletedTools: vi.fn().mockReturnValue([]),
87
+ get hasPendingTools() {
88
+ return false;
89
+ },
90
+ get pendingToolsCount() {
91
+ return 0;
92
+ },
93
+ addToolResponsesToHistory: vi.fn(),
94
+ sendCompletedToolsToLlm: vi.fn().mockImplementation(async function* () {}),
95
+ cancelPendingTools: vi.fn(),
96
+ setTaskStateAndPublishUpdate: vi.fn(),
97
+ dispose: vi.fn(),
98
+ getMetadata: vi.fn().mockResolvedValue({}),
99
+ geminiClient: {
100
+ initialize: vi.fn().mockResolvedValue(undefined),
101
+ },
102
+ toSDKTask: () => ({
103
+ id: taskId,
104
+ contextId,
105
+ kind: 'task',
106
+ status: { state: 'working', timestamp: new Date().toISOString() },
107
+ metadata: {},
108
+ history: [],
109
+ artifacts: [],
110
+ }),
111
+ });
112
+
113
+ const MockTask = vi.fn().mockImplementation(mockTaskInstance);
114
+ (MockTask as unknown as { create: Mock }).create = vi
115
+ .fn()
116
+ .mockImplementation(async (taskId: string, contextId: string) =>
117
+ mockTaskInstance(taskId, contextId),
118
+ );
119
+
120
+ return { Task: MockTask };
121
+ });
122
+
123
+ describe('CoderAgentExecutor', () => {
124
+ let executor: CoderAgentExecutor;
125
+ let mockTaskStore: TaskStore;
126
+ let mockEventBus: ExecutionEventBus;
127
+
128
+ beforeEach(() => {
129
+ vi.clearAllMocks();
130
+ mockTaskStore = {
131
+ save: vi.fn().mockResolvedValue(undefined),
132
+ load: vi.fn().mockResolvedValue(undefined),
133
+ delete: vi.fn().mockResolvedValue(undefined),
134
+ list: vi.fn().mockResolvedValue([]),
135
+ } as unknown as TaskStore;
136
+
137
+ mockEventBus = new EventEmitter() as unknown as ExecutionEventBus;
138
+ mockEventBus.publish = vi.fn();
139
+ mockEventBus.finished = vi.fn();
140
+
141
+ executor = new CoderAgentExecutor(mockTaskStore);
142
+ });
143
+
144
+ it('should distinguish between primary and secondary execution', async () => {
145
+ const taskId = 'test-task';
146
+ const contextId = 'test-context';
147
+
148
+ const mockSocket = new EventEmitter();
149
+ const requestContext = {
150
+ userMessage: {
151
+ messageId: 'msg-1',
152
+ taskId,
153
+ contextId,
154
+ parts: [{ kind: 'text', text: 'hi' }],
155
+ metadata: {
156
+ coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
157
+ },
158
+ },
159
+ } as unknown as RequestContext;
160
+
161
+ // Mock requestStorage for primary
162
+ (requestStorage.getStore as Mock).mockReturnValue({
163
+ req: { socket: mockSocket },
164
+ });
165
+
166
+ // First execution (Primary)
167
+ const primaryPromise = executor.execute(requestContext, mockEventBus);
168
+
169
+ // Give it enough time to reach line 490 in executor.ts
170
+ await new Promise((resolve) => setTimeout(resolve, 50));
171
+
172
+ expect(
173
+ (
174
+ executor as unknown as { executingTasks: Set<string> }
175
+ ).executingTasks.has(taskId),
176
+ ).toBe(true);
177
+ const wrapper = executor.getTask(taskId);
178
+ expect(wrapper).toBeDefined();
179
+
180
+ // Mock requestStorage for secondary
181
+ const secondarySocket = new EventEmitter();
182
+ (requestStorage.getStore as Mock).mockReturnValue({
183
+ req: { socket: secondarySocket },
184
+ });
185
+
186
+ const secondaryRequestContext = {
187
+ userMessage: {
188
+ messageId: 'msg-2',
189
+ taskId,
190
+ contextId,
191
+ parts: [{ kind: 'confirmation', callId: '1', outcome: 'proceed' }],
192
+ metadata: {
193
+ coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
194
+ },
195
+ },
196
+ } as unknown as RequestContext;
197
+
198
+ const secondaryPromise = executor.execute(
199
+ secondaryRequestContext,
200
+ mockEventBus,
201
+ );
202
+
203
+ // Secondary execution should NOT add to executingTasks (already there)
204
+ // and should return early after its loop
205
+ await secondaryPromise;
206
+
207
+ // Task should still be in executingTasks and NOT disposed
208
+ expect(
209
+ (
210
+ executor as unknown as { executingTasks: Set<string> }
211
+ ).executingTasks.has(taskId),
212
+ ).toBe(true);
213
+ expect(wrapper?.task.dispose).not.toHaveBeenCalled();
214
+
215
+ // Now simulate secondary socket closure - it should NOT affect primary
216
+ secondarySocket.emit('end');
217
+ expect(
218
+ (
219
+ executor as unknown as { executingTasks: Set<string> }
220
+ ).executingTasks.has(taskId),
221
+ ).toBe(true);
222
+ expect(wrapper?.task.dispose).not.toHaveBeenCalled();
223
+
224
+ // Set to terminal state to verify disposal on finish
225
+ wrapper!.task.taskState = 'completed';
226
+
227
+ // Now close primary socket
228
+ mockSocket.emit('end');
229
+
230
+ await primaryPromise;
231
+
232
+ expect(
233
+ (
234
+ executor as unknown as { executingTasks: Set<string> }
235
+ ).executingTasks.has(taskId),
236
+ ).toBe(false);
237
+ expect(wrapper?.task.dispose).toHaveBeenCalled();
238
+ });
239
+
240
+ it('should evict task from cache when it reaches terminal state', async () => {
241
+ const taskId = 'test-task-terminal';
242
+ const contextId = 'test-context';
243
+
244
+ const mockSocket = new EventEmitter();
245
+ (requestStorage.getStore as Mock).mockReturnValue({
246
+ req: { socket: mockSocket },
247
+ });
248
+
249
+ const requestContext = {
250
+ userMessage: {
251
+ messageId: 'msg-1',
252
+ taskId,
253
+ contextId,
254
+ parts: [{ kind: 'text', text: 'hi' }],
255
+ metadata: {
256
+ coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
257
+ },
258
+ },
259
+ } as unknown as RequestContext;
260
+
261
+ const primaryPromise = executor.execute(requestContext, mockEventBus);
262
+ await new Promise((resolve) => setTimeout(resolve, 50));
263
+
264
+ const wrapper = executor.getTask(taskId)!;
265
+ expect(wrapper).toBeDefined();
266
+ // Simulate terminal state
267
+ wrapper.task.taskState = 'completed';
268
+
269
+ // Finish primary execution
270
+ mockSocket.emit('end');
271
+ await primaryPromise;
272
+
273
+ expect(executor.getTask(taskId)).toBeUndefined();
274
+ expect(wrapper.task.dispose).toHaveBeenCalled();
275
+ });
276
+
277
+ it('should yield the turn and transition to input-required if tools are pending', async () => {
278
+ const taskId = 'test-task-pending-tools';
279
+ const contextId = 'test-context';
280
+
281
+ const mockSocket = new EventEmitter();
282
+ (requestStorage.getStore as Mock).mockReturnValue({
283
+ req: { socket: mockSocket },
284
+ });
285
+
286
+ // Pre-create the task to safely modify its mocked methods before execution
287
+ const wrapper = await executor.createTask(
288
+ taskId,
289
+ contextId,
290
+ undefined,
291
+ mockEventBus,
292
+ );
293
+ const hasPendingToolsSpy = vi
294
+ .spyOn(wrapper.task, 'hasPendingTools', 'get')
295
+ .mockReturnValue(true);
296
+ vi.spyOn(wrapper.task, 'pendingToolsCount', 'get').mockReturnValue(1);
297
+
298
+ const requestContext = {
299
+ userMessage: {
300
+ messageId: 'msg-1',
301
+ taskId,
302
+ contextId,
303
+ parts: [{ kind: 'confirmation', callId: '1', outcome: 'proceed' }],
304
+ metadata: {
305
+ coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
306
+ },
307
+ },
308
+ } as unknown as RequestContext;
309
+
310
+ await executor.execute(requestContext, mockEventBus);
311
+
312
+ // Assert that the executor yielded the turn correctly without further progression
313
+ expect(hasPendingToolsSpy).toHaveBeenCalled();
314
+ expect(wrapper.task.getAndClearCompletedTools).not.toHaveBeenCalled();
315
+ expect(wrapper.task.sendCompletedToolsToLlm).not.toHaveBeenCalled();
316
+ expect(wrapper.task.setTaskStateAndPublishUpdate).toHaveBeenCalledWith(
317
+ 'input-required',
318
+ expect.any(Object),
319
+ undefined,
320
+ undefined,
321
+ true,
322
+ );
323
+ });
324
+
325
+ it('cancelTask should abort the active execution loop', async () => {
326
+ const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
327
+ const taskId = 'test-task-to-cancel';
328
+ const contextId = 'test-context';
329
+
330
+ const mockSocket = new EventEmitter();
331
+ (requestStorage.getStore as Mock).mockReturnValue({
332
+ req: { socket: mockSocket },
333
+ });
334
+
335
+ const requestContext = {
336
+ userMessage: {
337
+ messageId: 'msg-1',
338
+ taskId,
339
+ contextId,
340
+ parts: [{ kind: 'text', text: 'a long running prompt' }],
341
+ metadata: {
342
+ coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
343
+ },
344
+ },
345
+ } as unknown as RequestContext;
346
+
347
+ // Don't await this, let it run in the background.
348
+ let primaryError: Error | null = null;
349
+ const primaryPromise = executor.execute(requestContext, mockEventBus);
350
+ primaryPromise.catch((err) => {
351
+ primaryError = err as Error;
352
+ });
353
+
354
+ // Poll until the task is registered in the executor to avoid flaky timeouts in slow CI environments.
355
+ let attempts = 0;
356
+ while (!executor.getTask(taskId)) {
357
+ if (primaryError) {
358
+ throw new Error(`Primary execution failed early: ${primaryError}`);
359
+ }
360
+ if (attempts++ > 100) {
361
+ // 100 * 5ms = 500ms timeout
362
+ throw new Error('Timed out waiting for task to be registered');
363
+ }
364
+ await new Promise((resolve) => setTimeout(resolve, 5));
365
+ }
366
+
367
+ const wrapper = executor.getTask(taskId);
368
+ expect(wrapper).toBeDefined();
369
+ const setTaskStateSpy = vi
370
+ .spyOn(wrapper!.task, 'setTaskStateAndPublishUpdate')
371
+ .mockImplementation((newState) => {
372
+ // Make the mock realistic: actually update the state when called.
373
+ wrapper!.task.taskState = newState;
374
+ });
375
+
376
+ // Now, cancel the task.
377
+ await executor.cancelTask(taskId, mockEventBus);
378
+
379
+ // Verify that the abort method on the controller was called and state was updated.
380
+ expect(abortSpy).toHaveBeenCalledOnce();
381
+ expect(setTaskStateSpy).toHaveBeenCalledWith(
382
+ 'canceled',
383
+ expect.any(Object),
384
+ 'Task canceled by user request.',
385
+ undefined,
386
+ true,
387
+ );
388
+
389
+ // Clean up the test by allowing the promise to resolve.
390
+ // The abort call should have unblocked the acceptUserMessage generator.
391
+ await primaryPromise;
392
+
393
+ // Verify task is evicted from cache
394
+ expect(executor.getTask(taskId)).toBeUndefined();
395
+
396
+ abortSpy.mockRestore();
397
+ });
398
+
399
+ it('cancelTask should explicitly save task state to TaskStore and evict task during active aborts', async () => {
400
+ const taskId = 'test-task-active-abort-save';
401
+ const contextId = 'test-context';
402
+
403
+ const mockSocket = new EventEmitter();
404
+ (requestStorage.getStore as Mock).mockReturnValue({
405
+ req: { socket: mockSocket },
406
+ });
407
+
408
+ const requestContext = {
409
+ userMessage: {
410
+ messageId: 'msg-1',
411
+ taskId,
412
+ contextId,
413
+ parts: [{ kind: 'text', text: 'a long running prompt' }],
414
+ metadata: {
415
+ coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
416
+ },
417
+ },
418
+ } as unknown as RequestContext;
419
+
420
+ const primaryPromise = executor.execute(requestContext, mockEventBus);
421
+
422
+ // Wait for task to be registered
423
+ let attempts = 0;
424
+ while (!executor.getTask(taskId)) {
425
+ if (attempts++ > 100) {
426
+ throw new Error('Timed out waiting for task to be registered');
427
+ }
428
+ await new Promise((resolve) => setTimeout(resolve, 5));
429
+ }
430
+
431
+ const wrapper = executor.getTask(taskId)!;
432
+ const saveSpy = vi.spyOn(mockTaskStore, 'save');
433
+
434
+ // Now, cancel the task.
435
+ await executor.cancelTask(taskId, mockEventBus);
436
+
437
+ // Verify that the task state was saved to TaskStore during cancelTask
438
+ expect(saveSpy).toHaveBeenCalled();
439
+ expect(wrapper.task.dispose).toHaveBeenCalled();
440
+ expect(executor.getTask(taskId)).toBeUndefined();
441
+
442
+ // Clean up the test by allowing the promise to resolve.
443
+ await primaryPromise;
444
+ });
445
+
446
+ it('should allow executing a task that is in a terminal state by re-activating it', async () => {
447
+ const taskId = 'test-task-terminal-reactivate';
448
+ const contextId = 'test-context';
449
+
450
+ const mockSocket = new EventEmitter();
451
+ (requestStorage.getStore as Mock).mockReturnValue({
452
+ req: { socket: mockSocket },
453
+ });
454
+
455
+ const requestContext = {
456
+ userMessage: {
457
+ messageId: 'msg-1',
458
+ taskId,
459
+ contextId,
460
+ parts: [{ kind: 'text', text: 'hi' }],
461
+ metadata: {
462
+ coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
463
+ },
464
+ },
465
+ } as unknown as RequestContext;
466
+
467
+ // Create a task wrapper and set it to a terminal state in cache
468
+ const wrapper = await executor.createTask(
469
+ taskId,
470
+ contextId,
471
+ undefined,
472
+ mockEventBus,
473
+ );
474
+ wrapper.task.taskState = 'canceled';
475
+
476
+ // Now execute it again
477
+ const primaryPromise = executor.execute(requestContext, mockEventBus);
478
+
479
+ // Wait for task to be re-activated in the async execute loop
480
+ await new Promise((resolve) => setTimeout(resolve, 10));
481
+
482
+ // Verify task was re-activated to 'submitted' / 'working' and executed instead of being ignored
483
+ const runningWrapper = executor.getTask(taskId);
484
+ expect(runningWrapper).toBeDefined();
485
+ expect(runningWrapper!.task.taskState).not.toBe('canceled');
486
+
487
+ // Clean up the test execution loop
488
+ mockSocket.emit('end');
489
+ await primaryPromise;
490
+ });
491
+
492
+ it('should not evict or treat a new request as secondary when preceding request is canceled and winding down', async () => {
493
+ const taskId = 'test-repro-race-condition';
494
+ const contextId = 'test-context';
495
+
496
+ // Simulating real-world TaskStore save delay / I/O latency
497
+ vi.spyOn(mockTaskStore, 'save').mockImplementation(async () => {
498
+ await new Promise((resolve) => setTimeout(resolve, 50));
499
+ });
500
+
501
+ const mockSocket1 = new EventEmitter();
502
+ (requestStorage.getStore as Mock).mockReturnValue({
503
+ req: { socket: mockSocket1 },
504
+ });
505
+
506
+ const requestContext1 = {
507
+ userMessage: {
508
+ messageId: 'msg-1',
509
+ taskId,
510
+ contextId,
511
+ parts: [{ kind: 'text', text: 'prompt 1' }],
512
+ metadata: {
513
+ coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
514
+ },
515
+ },
516
+ } as unknown as RequestContext;
517
+
518
+ const primaryPromise = executor.execute(requestContext1, mockEventBus);
519
+
520
+ // Wait for the task to be registered
521
+ let attempts = 0;
522
+ while (!executor.getTask(taskId)) {
523
+ if (attempts++ > 100) {
524
+ throw new Error('Timed out waiting for task to be registered');
525
+ }
526
+ await new Promise((resolve) => setTimeout(resolve, 5));
527
+ }
528
+
529
+ const wrapper1 = executor.getTask(taskId)!;
530
+ expect(wrapper1).toBeDefined();
531
+
532
+ // Mock setTaskStateAndPublishUpdate realistically to trigger the buggy eviction behavior
533
+ vi.spyOn(wrapper1.task, 'setTaskStateAndPublishUpdate').mockImplementation(
534
+ (newState) => {
535
+ wrapper1.task.taskState = newState;
536
+ },
537
+ );
538
+
539
+ // Cancel the task (Request 1)
540
+ await executor.cancelTask(taskId, mockEventBus);
541
+
542
+ // Now immediately start Request 2 with the same taskId
543
+ const mockSocket2 = new EventEmitter();
544
+ (requestStorage.getStore as Mock).mockReturnValue({
545
+ req: { socket: mockSocket2 },
546
+ });
547
+
548
+ const requestContext2 = {
549
+ userMessage: {
550
+ messageId: 'msg-2',
551
+ taskId,
552
+ contextId,
553
+ parts: [{ kind: 'text', text: 'prompt 2' }],
554
+ metadata: {
555
+ coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
556
+ },
557
+ },
558
+ } as unknown as RequestContext;
559
+
560
+ const secondaryPromise = executor.execute(requestContext2, mockEventBus);
561
+
562
+ let secondaryResolved = false;
563
+ void secondaryPromise
564
+ .then(() => {
565
+ secondaryResolved = true;
566
+ })
567
+ .catch(() => {});
568
+
569
+ // Give it a moment to initialize Request 2's task in execute loop
570
+ await new Promise((resolve) => setTimeout(resolve, 20));
571
+
572
+ // Request 2 should NOT have resolved prematurely (it should be running as a primary loop, not secondary)
573
+ expect(secondaryResolved).toBe(false);
574
+
575
+ // Await primaryPromise to let Request 1 completely wind down
576
+ await primaryPromise;
577
+
578
+ // Verify task 2 was NOT evicted/destroyed
579
+ const wrapper2 = executor.getTask(taskId);
580
+ expect(wrapper2).toBeDefined();
581
+ expect(wrapper2).not.toBe(wrapper1);
582
+
583
+ // Clean up Request 2
584
+ mockSocket2.emit('end');
585
+ await secondaryPromise;
586
+ });
587
+ });
packages/a2a-server/src/agent/executor.ts ADDED
@@ -0,0 +1,939 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { Task as SDKTask } from '@a2a-js/sdk';
8
+ import type {
9
+ TaskStore,
10
+ AgentExecutor,
11
+ AgentExecutionEvent,
12
+ RequestContext,
13
+ ExecutionEventBus,
14
+ } from '@a2a-js/sdk/server';
15
+ import {
16
+ GeminiEventType,
17
+ SimpleExtensionLoader,
18
+ type ToolCallRequestInfo,
19
+ type Config,
20
+ resolveToRealPath,
21
+ } from '@google/gemini-cli-core';
22
+ import { v4 as uuidv4 } from 'uuid';
23
+
24
+ import { logger } from '../utils/logger.js';
25
+ import {
26
+ CoderAgentEvent,
27
+ getPersistedState,
28
+ setPersistedState,
29
+ type StateChange,
30
+ type AgentSettings,
31
+ type PersistedStateMetadata,
32
+ getContextIdFromMetadata,
33
+ getAgentSettingsFromMetadata,
34
+ } from '../types.js';
35
+ import {
36
+ loadConfig,
37
+ setTargetDir,
38
+ setIsTrusted,
39
+ envStorage,
40
+ cwdSymbol,
41
+ loadEnvironment,
42
+ type TaskEnv,
43
+ } from '../config/config.js';
44
+ import { loadSettings } from '../config/settings.js';
45
+ import { loadExtensions } from '../config/extension.js';
46
+ import { Task } from './task.js';
47
+ import { requestStorage } from '../http/requestStorage.js';
48
+ import { pushTaskStateFailed } from '../utils/executor_utils.js';
49
+
50
+ function validateWorkspacePath(workspacePath?: string): string {
51
+ const trimmed = workspacePath?.trim();
52
+ if (!trimmed) {
53
+ return process.env['CODER_AGENT_WORKSPACE_PATH'] ?? process.cwd();
54
+ }
55
+ return resolveToRealPath(trimmed);
56
+ }
57
+
58
+ /**
59
+ * Provides a wrapper for Task. Passes data from Task to SDKTask.
60
+ * The idea is to use this class inside CoderAgentExecutor to replace Task.
61
+ */
62
+ class TaskWrapper {
63
+ task: Task;
64
+ agentSettings: AgentSettings;
65
+
66
+ constructor(task: Task, agentSettings: AgentSettings) {
67
+ this.task = task;
68
+ this.agentSettings = agentSettings;
69
+ }
70
+
71
+ get id() {
72
+ return this.task.id;
73
+ }
74
+
75
+ toSDKTask(): SDKTask {
76
+ const persistedState: PersistedStateMetadata = {
77
+ _agentSettings: this.agentSettings,
78
+ _taskState: this.task.taskState,
79
+ };
80
+
81
+ const sdkTask: SDKTask = {
82
+ id: this.task.id,
83
+ contextId: this.task.contextId,
84
+ kind: 'task',
85
+ status: {
86
+ state: this.task.taskState,
87
+ timestamp: new Date().toISOString(),
88
+ },
89
+ metadata: setPersistedState({}, persistedState),
90
+ history: [],
91
+ artifacts: [],
92
+ };
93
+ sdkTask.metadata!['_contextId'] = this.task.contextId;
94
+ return sdkTask;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * CoderAgentExecutor implements the agent's core logic for code generation.
100
+ */
101
+ export class CoderAgentExecutor implements AgentExecutor {
102
+ private tasks: Map<string, TaskWrapper> = new Map();
103
+ // Track tasks with an active execution loop.
104
+ private executingTasks = new Map<string, AbortController>();
105
+ private activeAbortControllers = new Map<string, Set<AbortController>>();
106
+ // Track tasks currently initializing to prevent race conditions.
107
+ private initializingTasks = new Set<string>();
108
+ private initializationPromises = new Map<string, Promise<TaskWrapper>>();
109
+ // Track explicitly canceled abort controllers to handle cancellation during initialization.
110
+ private explicitlyCanceledTasks = new Set<AbortController>();
111
+
112
+ constructor(private taskStore?: TaskStore) {}
113
+
114
+ private async getConfigWithEnv(
115
+ agentSettings: AgentSettings,
116
+ taskId: string,
117
+ isTrusted: boolean,
118
+ workspaceRoot: string,
119
+ ): Promise<Config> {
120
+ const settings = loadSettings(workspaceRoot, isTrusted);
121
+ const extensions = loadExtensions(workspaceRoot, isTrusted);
122
+ return loadConfig(
123
+ settings,
124
+ new SimpleExtensionLoader(extensions),
125
+ taskId,
126
+ isTrusted,
127
+ workspaceRoot,
128
+ );
129
+ }
130
+
131
+ private async runInIsolatedEnv<T>(
132
+ agentSettings: AgentSettings,
133
+ fn: (isTrusted: boolean, workspaceRoot: string) => Promise<T>,
134
+ ): Promise<T> {
135
+ const workspaceRoot = await setTargetDir(agentSettings);
136
+ const isTrusted = setIsTrusted(agentSettings, workspaceRoot);
137
+
138
+ const envVars: TaskEnv = { ...process.env };
139
+ envVars['GEMINI_CLI_TRUST_WORKSPACE'] = isTrusted ? 'true' : 'false';
140
+ envVars[cwdSymbol] = workspaceRoot;
141
+
142
+ return envStorage.run(envVars, async () => {
143
+ const loadedEnv = await loadEnvironment(isTrusted, workspaceRoot);
144
+ Object.assign(envVars, loadedEnv);
145
+ return fn(isTrusted, workspaceRoot);
146
+ });
147
+ }
148
+
149
+ private cleanupAndEvictTask(taskId: string, expectedWrapper?: TaskWrapper) {
150
+ const wrapper = this.tasks.get(taskId);
151
+ if (wrapper) {
152
+ if (expectedWrapper && wrapper !== expectedWrapper) {
153
+ logger.info(
154
+ `[CoderAgentExecutor] cleanupAndEvictTask: wrapper mismatch for task ${taskId}. Skipping eviction.`,
155
+ );
156
+ return;
157
+ }
158
+ wrapper.task.dispose();
159
+ this.tasks.delete(taskId);
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Reconstructs TaskWrapper from SDKTask.
165
+ */
166
+ async reconstruct(
167
+ sdkTask: SDKTask,
168
+ eventBus?: ExecutionEventBus,
169
+ ): Promise<TaskWrapper> {
170
+ const metadata = sdkTask.metadata || {};
171
+ const persistedState = getPersistedState(metadata);
172
+
173
+ if (!persistedState) {
174
+ throw new Error(
175
+ `Cannot reconstruct task ${sdkTask.id}: missing persisted state in metadata.`,
176
+ );
177
+ }
178
+
179
+ let agentSettings: AgentSettings;
180
+ try {
181
+ agentSettings = {
182
+ ...(persistedState._agentSettings ?? {}),
183
+ workspacePath: validateWorkspacePath(
184
+ persistedState._agentSettings?.workspacePath,
185
+ ),
186
+ isTrusted: false,
187
+ };
188
+ } catch (error) {
189
+ logger.error(
190
+ `[CoderAgentExecutor] Invalid workspace path in persisted state for task ${sdkTask.id}:`,
191
+ error,
192
+ );
193
+ if (eventBus) {
194
+ void pushTaskStateFailed(
195
+ error,
196
+ eventBus,
197
+ sdkTask.id,
198
+ sdkTask.contextId,
199
+ );
200
+ }
201
+ throw error; // Re-throw to be caught by caller
202
+ }
203
+
204
+ return this.runInIsolatedEnv(
205
+ agentSettings,
206
+ async (isTrusted, workspaceRoot) => {
207
+ const config = await this.getConfigWithEnv(
208
+ agentSettings,
209
+ sdkTask.id,
210
+ isTrusted,
211
+ workspaceRoot,
212
+ );
213
+ const contextId: string =
214
+ getContextIdFromMetadata(metadata) || sdkTask.contextId;
215
+ const runtimeTask = await Task.create(
216
+ sdkTask.id,
217
+ contextId,
218
+ config,
219
+ eventBus,
220
+ agentSettings.autoExecute,
221
+ );
222
+ runtimeTask.taskState = persistedState._taskState;
223
+ await runtimeTask.geminiClient.initialize();
224
+
225
+ const wrapper = new TaskWrapper(runtimeTask, agentSettings);
226
+ this.tasks.set(sdkTask.id, wrapper);
227
+ logger.info(`Task ${sdkTask.id} reconstructed from store.`);
228
+ return wrapper;
229
+ },
230
+ );
231
+ }
232
+
233
+ async createTask(
234
+ taskId: string,
235
+ contextId: string,
236
+ agentSettingsInput?: AgentSettings,
237
+ eventBus?: ExecutionEventBus,
238
+ ): Promise<TaskWrapper> {
239
+ const agentSettings: AgentSettings = agentSettingsInput || {
240
+ kind: CoderAgentEvent.StateAgentSettingsEvent,
241
+ workspacePath: process.cwd(),
242
+ };
243
+ return this.runInIsolatedEnv(
244
+ agentSettings,
245
+ async (isTrusted, workspaceRoot) => {
246
+ const config = await this.getConfigWithEnv(
247
+ agentSettings,
248
+ taskId,
249
+ isTrusted,
250
+ workspaceRoot,
251
+ );
252
+ const runtimeTask = await Task.create(
253
+ taskId,
254
+ contextId,
255
+ config,
256
+ eventBus,
257
+ agentSettings.autoExecute,
258
+ );
259
+ await runtimeTask.geminiClient.initialize();
260
+
261
+ const wrapper = new TaskWrapper(runtimeTask, agentSettings);
262
+ this.tasks.set(taskId, wrapper);
263
+ logger.info(`New task ${taskId} created.`);
264
+ return wrapper;
265
+ },
266
+ );
267
+ }
268
+
269
+ getTask(taskId: string): TaskWrapper | undefined {
270
+ return this.tasks.get(taskId);
271
+ }
272
+
273
+ getAllTasks(): TaskWrapper[] {
274
+ return Array.from(this.tasks.values());
275
+ }
276
+
277
+ cancelTask = async (
278
+ taskId: string,
279
+ eventBus: ExecutionEventBus,
280
+ ): Promise<void> => {
281
+ logger.info(
282
+ `[CoderAgentExecutor] Received cancel request for task ${taskId}`,
283
+ );
284
+
285
+ const abortControllers = this.activeAbortControllers.get(taskId);
286
+ if (abortControllers && abortControllers.size > 0) {
287
+ logger.info(
288
+ `[CoderAgentExecutor] Aborting ${abortControllers.size} active execution loop(s) for task ${taskId}.`,
289
+ );
290
+ // Abort first to ensure loops are stopped.
291
+ for (const controller of Array.from(abortControllers)) {
292
+ this.explicitlyCanceledTasks.add(controller);
293
+ controller.abort();
294
+ }
295
+
296
+ // Then, attempt to update state and persist.
297
+ const wrapper = this.tasks.get(taskId);
298
+ if (wrapper) {
299
+ const { task } = wrapper;
300
+ task.cancelPendingTools('Task canceled by user request.');
301
+ task.setTaskStateAndPublishUpdate(
302
+ 'canceled',
303
+ { kind: CoderAgentEvent.StateChangeEvent },
304
+ 'Task canceled by user request.',
305
+ undefined,
306
+ true,
307
+ );
308
+ this.cleanupAndEvictTask(taskId, wrapper);
309
+ try {
310
+ await this.taskStore?.save(wrapper.toSDKTask());
311
+ logger.info(
312
+ `[CoderAgentExecutor] Task ${taskId} state CANCELED saved during active abort.`,
313
+ );
314
+ } catch (saveError) {
315
+ logger.error(
316
+ `[CoderAgentExecutor] Failed to save task ${taskId} state during active abort:`,
317
+ saveError,
318
+ );
319
+ }
320
+ }
321
+ return;
322
+ }
323
+
324
+ // If there is no active execution loop, the task is idle.
325
+ // We can clean it up directly.
326
+ logger.info(
327
+ `[CoderAgentExecutor] No active execution for task ${taskId}. Cleaning up directly.`,
328
+ );
329
+ const wrapper = this.tasks.get(taskId);
330
+
331
+ if (!wrapper) {
332
+ logger.warn(
333
+ `[CoderAgentExecutor] Task ${taskId} not found for cancellation.`,
334
+ );
335
+ eventBus.publish({
336
+ kind: 'status-update',
337
+ taskId,
338
+ contextId: uuidv4(),
339
+ status: {
340
+ state: 'failed',
341
+ message: {
342
+ kind: 'message',
343
+ role: 'agent',
344
+ parts: [{ kind: 'text', text: `Task ${taskId} not found.` }],
345
+ messageId: uuidv4(),
346
+ taskId,
347
+ },
348
+ },
349
+ final: true,
350
+ });
351
+ return;
352
+ }
353
+
354
+ const { task } = wrapper;
355
+
356
+ if (task.taskState === 'canceled' || task.taskState === 'failed') {
357
+ logger.info(
358
+ `[CoderAgentExecutor] Task ${taskId} is already in a final state: ${task.taskState}. No action needed for cancellation.`,
359
+ );
360
+ eventBus.publish({
361
+ kind: 'status-update',
362
+ taskId,
363
+ contextId: task.contextId,
364
+ status: {
365
+ state: task.taskState,
366
+ message: {
367
+ kind: 'message',
368
+ role: 'agent',
369
+ parts: [
370
+ {
371
+ kind: 'text',
372
+ text: `Task ${taskId} is already ${task.taskState}.`,
373
+ },
374
+ ],
375
+ messageId: uuidv4(),
376
+ taskId,
377
+ },
378
+ },
379
+ final: true,
380
+ });
381
+ return;
382
+ }
383
+
384
+ try {
385
+ logger.info(
386
+ `[CoderAgentExecutor] Initiating cancellation for task ${taskId}.`,
387
+ );
388
+ task.cancelPendingTools('Task canceled by user request.');
389
+
390
+ const stateChange: StateChange = {
391
+ kind: CoderAgentEvent.StateChangeEvent,
392
+ };
393
+ task.setTaskStateAndPublishUpdate(
394
+ 'canceled',
395
+ stateChange,
396
+ 'Task canceled by user request.',
397
+ undefined,
398
+ true,
399
+ );
400
+ logger.info(
401
+ `[CoderAgentExecutor] Task ${taskId} cancellation processed. Saving state.`,
402
+ );
403
+ // Cleanup listener subscriptions and evict synchronously before async save
404
+ this.cleanupAndEvictTask(taskId, wrapper);
405
+
406
+ await this.taskStore?.save(wrapper.toSDKTask());
407
+ logger.info(`[CoderAgentExecutor] Task ${taskId} state CANCELED saved.`);
408
+ } catch (error) {
409
+ const errorMessage =
410
+ error instanceof Error ? error.message : 'Unknown error';
411
+ logger.error(
412
+ `[CoderAgentExecutor] Error during task cancellation for ${taskId}: ${errorMessage}`,
413
+ error,
414
+ );
415
+ eventBus.publish({
416
+ kind: 'status-update',
417
+ taskId,
418
+ contextId: task.contextId,
419
+ status: {
420
+ state: 'failed',
421
+ message: {
422
+ kind: 'message',
423
+ role: 'agent',
424
+ parts: [
425
+ {
426
+ kind: 'text',
427
+ text: `Failed to process cancellation for task ${taskId}: ${errorMessage}`,
428
+ },
429
+ ],
430
+ messageId: uuidv4(),
431
+ taskId,
432
+ },
433
+ },
434
+ final: true,
435
+ });
436
+ }
437
+ };
438
+
439
+ async execute(
440
+ requestContext: RequestContext,
441
+ eventBus: ExecutionEventBus,
442
+ ): Promise<void> {
443
+ const userMessage = requestContext.userMessage;
444
+ const sdkTask = requestContext.task;
445
+
446
+ const taskId = sdkTask?.id || userMessage.taskId || uuidv4();
447
+ const contextId: string =
448
+ userMessage.contextId ||
449
+ sdkTask?.contextId ||
450
+ getContextIdFromMetadata(sdkTask?.metadata) ||
451
+ uuidv4();
452
+
453
+ let agentSettings: AgentSettings;
454
+ try {
455
+ const existingWrapper = this.tasks.get(taskId);
456
+ if (existingWrapper) {
457
+ agentSettings = existingWrapper.agentSettings;
458
+ } else if (sdkTask) {
459
+ const persistedState = getPersistedState(sdkTask.metadata || {});
460
+ agentSettings = {
461
+ kind: CoderAgentEvent.StateAgentSettingsEvent,
462
+ ...(persistedState?._agentSettings ?? {}),
463
+ workspacePath: validateWorkspacePath(
464
+ persistedState?._agentSettings?.workspacePath,
465
+ ),
466
+ isTrusted: false,
467
+ };
468
+ } else {
469
+ const rawAgentSettings = getAgentSettingsFromMetadata(
470
+ userMessage.metadata,
471
+ );
472
+ agentSettings = {
473
+ kind: CoderAgentEvent.StateAgentSettingsEvent,
474
+ ...(rawAgentSettings || {}),
475
+ workspacePath: validateWorkspacePath(rawAgentSettings?.workspacePath),
476
+ isTrusted: false,
477
+ };
478
+ }
479
+ } catch (error) {
480
+ logger.error(
481
+ `[CoderAgentExecutor] Invalid workspace path during execution setup for task ${taskId}:`,
482
+ error,
483
+ );
484
+ if (eventBus) {
485
+ void pushTaskStateFailed(error, eventBus, taskId, contextId);
486
+ }
487
+ throw error;
488
+ }
489
+
490
+ return this.runInIsolatedEnv(agentSettings, async () => {
491
+ try {
492
+ logger.info(
493
+ `[CoderAgentExecutor] Executing for taskId: ${taskId}, contextId: ${contextId}`,
494
+ );
495
+ logger.info(
496
+ `[CoderAgentExecutor] userMessage: ${JSON.stringify(userMessage)}`,
497
+ );
498
+ eventBus.on('event', (event: AgentExecutionEvent) =>
499
+ logger.info('[EventBus event]: ', event),
500
+ );
501
+
502
+ const store = requestStorage.getStore();
503
+ if (!store) {
504
+ logger.error(
505
+ '[CoderAgentExecutor] Could not get request from async local storage. Cancellation on socket close will not be handled for this request.',
506
+ );
507
+ }
508
+
509
+ const abortController = new AbortController();
510
+ const abortSignal = abortController.signal;
511
+
512
+ if (!this.activeAbortControllers.has(taskId)) {
513
+ this.activeAbortControllers.set(taskId, new Set());
514
+ }
515
+ this.activeAbortControllers.get(taskId)!.add(abortController);
516
+
517
+ let proceedToMainLoop = false;
518
+ let wrapper: TaskWrapper | undefined;
519
+ let isPrimaryExecution = false;
520
+
521
+ try {
522
+ if (store) {
523
+ const socket = store.req.socket;
524
+ const onSocketEnd = () => {
525
+ logger.info(
526
+ `[CoderAgentExecutor] Socket ended for message ${userMessage.messageId} (task ${taskId}). Aborting execution loop.`,
527
+ );
528
+ if (!abortController.signal.aborted) {
529
+ abortController.abort();
530
+ }
531
+ socket.removeListener('end', onSocketEnd);
532
+ };
533
+ socket.on('end', onSocketEnd);
534
+ socket.once('close', () =>
535
+ socket.removeListener('end', onSocketEnd),
536
+ );
537
+ abortSignal.addEventListener('abort', () =>
538
+ socket.removeListener('end', onSocketEnd),
539
+ );
540
+ logger.info(
541
+ `[CoderAgentExecutor] Socket close handler set up for task ${taskId}.`,
542
+ );
543
+ }
544
+
545
+ // Check if the task is currently initializing
546
+ if (this.initializingTasks.has(taskId)) {
547
+ logger.info(
548
+ `[CoderAgentExecutor] Task ${taskId} is currently initializing. Waiting for initialization to complete.`,
549
+ );
550
+ const initPromise = this.initializationPromises.get(taskId);
551
+ if (initPromise) {
552
+ try {
553
+ wrapper = await initPromise;
554
+ } catch {
555
+ logger.error(
556
+ `[CoderAgentExecutor] Failed to wait for task ${taskId} initialization.`,
557
+ );
558
+ return;
559
+ }
560
+ }
561
+ }
562
+
563
+ if (!wrapper) {
564
+ this.initializingTasks.add(taskId);
565
+ const initPromise = (async () => {
566
+ let initializedWrapper: TaskWrapper | undefined;
567
+ initializedWrapper = this.tasks.get(taskId);
568
+
569
+ if (initializedWrapper) {
570
+ initializedWrapper.task.eventBus = eventBus;
571
+ logger.info(
572
+ `[CoderAgentExecutor] Task ${taskId} found in memory cache.`,
573
+ );
574
+ } else if (sdkTask) {
575
+ logger.info(
576
+ `[CoderAgentExecutor] Task ${taskId} found in TaskStore. Reconstructing...`,
577
+ );
578
+ try {
579
+ initializedWrapper = await this.reconstruct(
580
+ sdkTask,
581
+ eventBus,
582
+ );
583
+ } catch (e) {
584
+ logger.error(
585
+ `[CoderAgentExecutor] Aborting execution due to failed task reconstruction for task ${taskId}:`,
586
+ e,
587
+ );
588
+ throw e;
589
+ }
590
+ } else {
591
+ initializedWrapper = await this.createTask(
592
+ taskId,
593
+ contextId,
594
+ agentSettings,
595
+ eventBus,
596
+ );
597
+ const newTaskSDK = initializedWrapper.toSDKTask();
598
+ eventBus.publish({
599
+ ...newTaskSDK,
600
+ kind: 'task',
601
+ status: {
602
+ state: 'submitted',
603
+ timestamp: new Date().toISOString(),
604
+ },
605
+ history: [userMessage],
606
+ });
607
+ try {
608
+ await this.taskStore?.save(newTaskSDK);
609
+ logger.info(
610
+ `[CoderAgentExecutor] New task ${taskId} saved to store.`,
611
+ );
612
+ } catch (saveError) {
613
+ logger.error(
614
+ `[CoderAgentExecutor] Failed to save new task ${taskId} to store:`,
615
+ saveError,
616
+ );
617
+ }
618
+ }
619
+ return initializedWrapper;
620
+ })();
621
+
622
+ this.initializationPromises.set(taskId, initPromise);
623
+
624
+ try {
625
+ wrapper = await initPromise;
626
+ } catch {
627
+ // Error is already handled/logged inside the promise
628
+ return;
629
+ } finally {
630
+ this.initializingTasks.delete(taskId);
631
+ this.initializationPromises.delete(taskId);
632
+ }
633
+ }
634
+
635
+ if (!wrapper) {
636
+ logger.error(
637
+ `[CoderAgentExecutor] Task ${taskId} is unexpectedly undefined after load/create.`,
638
+ );
639
+ return;
640
+ }
641
+
642
+ const currentTask = wrapper.task;
643
+
644
+ if (
645
+ ['canceled', 'failed', 'completed'].includes(currentTask.taskState)
646
+ ) {
647
+ logger.info(
648
+ `[CoderAgentExecutor] Re-activating task ${taskId} from terminal state ${currentTask.taskState}.`,
649
+ );
650
+ currentTask.taskState = 'submitted';
651
+ }
652
+
653
+ if (abortSignal.aborted) {
654
+ logger.warn(
655
+ `[CoderAgentExecutor] Task ${taskId} was aborted during initialization.`,
656
+ );
657
+ const isExplicitCancel =
658
+ this.explicitlyCanceledTasks.has(abortController);
659
+ const finalState = isExplicitCancel ? 'canceled' : 'input-required';
660
+ const message = isExplicitCancel
661
+ ? 'Task canceled by user request.'
662
+ : 'Execution aborted by client.';
663
+ currentTask.setTaskStateAndPublishUpdate(
664
+ finalState,
665
+ { kind: CoderAgentEvent.StateChangeEvent },
666
+ message,
667
+ undefined,
668
+ true,
669
+ );
670
+ try {
671
+ await this.taskStore?.save(wrapper.toSDKTask());
672
+ } catch (saveError) {
673
+ logger.error(
674
+ `[CoderAgentExecutor] Failed to save task ${taskId} state:`,
675
+ saveError,
676
+ );
677
+ }
678
+ if (isExplicitCancel) {
679
+ this.cleanupAndEvictTask(taskId, wrapper);
680
+ }
681
+ return;
682
+ }
683
+
684
+ const activeController = this.executingTasks.get(taskId);
685
+ if (activeController && !activeController.signal.aborted) {
686
+ logger.info(
687
+ `[CoderAgentExecutor] Task ${taskId} has a pending execution. Processing message and yielding.`,
688
+ );
689
+ currentTask.eventBus = eventBus;
690
+ try {
691
+ for await (const _ of currentTask.acceptUserMessage(
692
+ requestContext,
693
+ abortController.signal,
694
+ )) {
695
+ logger.info(
696
+ `[CoderAgentExecutor] Processing user message ${userMessage.messageId} in secondary execution loop for task ${taskId}.`,
697
+ );
698
+ }
699
+ } catch (error) {
700
+ if (!abortController.signal.aborted) {
701
+ throw error;
702
+ }
703
+ logger.info(
704
+ `[CoderAgentExecutor] Secondary execution loop for task ${taskId} was aborted.`,
705
+ );
706
+ }
707
+ return;
708
+ }
709
+
710
+ isPrimaryExecution = true;
711
+
712
+ proceedToMainLoop = true;
713
+ } finally {
714
+ this.explicitlyCanceledTasks.delete(abortController);
715
+ if (!proceedToMainLoop) {
716
+ const controllers = this.activeAbortControllers.get(taskId);
717
+ if (controllers) {
718
+ controllers.delete(abortController);
719
+ if (controllers.size === 0) {
720
+ this.activeAbortControllers.delete(taskId);
721
+ }
722
+ }
723
+ }
724
+ }
725
+
726
+ const currentTask = wrapper.task;
727
+ try {
728
+ logger.info(
729
+ `[CoderAgentExecutor] Starting main execution for message ${userMessage.messageId} for task ${taskId}.`,
730
+ );
731
+ this.executingTasks.set(taskId, abortController);
732
+
733
+ let agentTurnActive = true;
734
+ logger.info(
735
+ `[CoderAgentExecutor] Task ${taskId}: Processing user turn.`,
736
+ );
737
+ let agentEvents = currentTask.acceptUserMessage(
738
+ requestContext,
739
+ abortSignal,
740
+ );
741
+
742
+ while (agentTurnActive) {
743
+ if (abortSignal.aborted) {
744
+ logger.info(
745
+ `[CoderAgentExecutor] Task ${taskId} aborted before turn. Exiting loop.`,
746
+ );
747
+ throw new Error('Execution aborted');
748
+ }
749
+ logger.info(
750
+ `[CoderAgentExecutor] Task ${taskId}: Processing agent turn (LLM stream).`,
751
+ );
752
+ const toolCallRequests: ToolCallRequestInfo[] = [];
753
+ for await (const event of agentEvents) {
754
+ if (abortSignal.aborted) {
755
+ logger.warn(
756
+ `[CoderAgentExecutor] Task ${taskId}: Abort signal received during agent event processing.`,
757
+ );
758
+ throw new Error('Execution aborted');
759
+ }
760
+ if (event.type === GeminiEventType.ToolCallRequest) {
761
+ toolCallRequests.push(event.value);
762
+ continue;
763
+ }
764
+ await currentTask.acceptAgentMessage(event);
765
+ }
766
+
767
+ if (abortSignal.aborted) throw new Error('Execution aborted');
768
+
769
+ if (toolCallRequests.length > 0) {
770
+ logger.info(
771
+ `[CoderAgentExecutor] Task ${taskId}: Found ${toolCallRequests.length} tool call requests. Scheduling as a batch.`,
772
+ );
773
+ await currentTask.scheduleToolCalls(
774
+ toolCallRequests,
775
+ abortSignal,
776
+ );
777
+ }
778
+
779
+ logger.info(
780
+ `[CoderAgentExecutor] Task ${taskId}: Waiting for pending tools if any.`,
781
+ );
782
+ await currentTask.waitForPendingTools();
783
+ logger.info(
784
+ `[CoderAgentExecutor] Task ${taskId}: All pending tools completed or none were pending.`,
785
+ );
786
+
787
+ if (abortSignal.aborted) throw new Error('Execution aborted');
788
+
789
+ if (currentTask.hasPendingTools) {
790
+ logger.info(
791
+ `[CoderAgentExecutor] Task ${taskId}: There are still ${currentTask.pendingToolsCount} pending tools waiting for approval. Yielding to user.`,
792
+ );
793
+ agentTurnActive = false;
794
+ } else {
795
+ const completedTools = currentTask.getAndClearCompletedTools();
796
+
797
+ if (completedTools.length > 0) {
798
+ if (
799
+ completedTools.every((tool) => tool.status === 'cancelled')
800
+ ) {
801
+ logger.info(
802
+ `[CoderAgentExecutor] Task ${taskId}: All tool calls were cancelled. Updating history and ending agent turn.`,
803
+ );
804
+ currentTask.addToolResponsesToHistory(completedTools);
805
+ agentTurnActive = false;
806
+ const stateChange: StateChange = {
807
+ kind: CoderAgentEvent.StateChangeEvent,
808
+ };
809
+ currentTask.setTaskStateAndPublishUpdate(
810
+ 'input-required',
811
+ stateChange,
812
+ undefined,
813
+ undefined,
814
+ true,
815
+ );
816
+ } else {
817
+ logger.info(
818
+ `[CoderAgentExecutor] Task ${taskId}: Found ${completedTools.length} completed tool calls. Sending results back to LLM.`,
819
+ );
820
+
821
+ agentEvents = currentTask.sendCompletedToolsToLlm(
822
+ completedTools,
823
+ abortSignal,
824
+ );
825
+ }
826
+ } else {
827
+ logger.info(
828
+ `[CoderAgentExecutor] Task ${taskId}: No more tool calls to process. Ending agent turn.`,
829
+ );
830
+ agentTurnActive = false;
831
+ }
832
+ }
833
+ }
834
+
835
+ logger.info(
836
+ `[CoderAgentExecutor] Task ${taskId}: Agent turn finished, setting to input-required.`,
837
+ );
838
+ const stateChange: StateChange = {
839
+ kind: CoderAgentEvent.StateChangeEvent,
840
+ };
841
+ currentTask.setTaskStateAndPublishUpdate(
842
+ 'input-required',
843
+ stateChange,
844
+ undefined,
845
+ undefined,
846
+ true,
847
+ );
848
+ } catch (error) {
849
+ if (abortSignal.aborted) {
850
+ logger.warn(
851
+ `[CoderAgentExecutor] Task ${taskId} execution aborted.`,
852
+ );
853
+ currentTask.cancelPendingTools('Execution aborted');
854
+ if (
855
+ currentTask.taskState !== 'canceled' &&
856
+ currentTask.taskState !== 'failed'
857
+ ) {
858
+ currentTask.setTaskStateAndPublishUpdate(
859
+ 'input-required',
860
+ { kind: CoderAgentEvent.StateChangeEvent },
861
+ 'Execution aborted by client.',
862
+ undefined,
863
+ true,
864
+ );
865
+ }
866
+ } else {
867
+ const errorMessage =
868
+ error instanceof Error ? error.message : 'Agent execution error';
869
+ logger.error(
870
+ `[CoderAgentExecutor] Error executing agent for task ${taskId}:`,
871
+ error,
872
+ );
873
+ currentTask.cancelPendingTools(errorMessage);
874
+ if (currentTask.taskState !== 'failed') {
875
+ const stateChange: StateChange = {
876
+ kind: CoderAgentEvent.StateChangeEvent,
877
+ };
878
+ currentTask.setTaskStateAndPublishUpdate(
879
+ 'failed',
880
+ stateChange,
881
+ errorMessage,
882
+ undefined,
883
+ true,
884
+ );
885
+ }
886
+ }
887
+ } finally {
888
+ if (isPrimaryExecution) {
889
+ const controllers = this.activeAbortControllers.get(taskId);
890
+ if (controllers) {
891
+ controllers.delete(abortController);
892
+ if (controllers.size === 0) {
893
+ this.activeAbortControllers.delete(taskId);
894
+ }
895
+ }
896
+ if (this.executingTasks.get(taskId) === abortController) {
897
+ this.executingTasks.delete(taskId);
898
+ }
899
+ if (
900
+ ['canceled', 'failed', 'completed'].includes(
901
+ currentTask.taskState,
902
+ )
903
+ ) {
904
+ this.cleanupAndEvictTask(taskId, wrapper);
905
+ }
906
+ logger.info(
907
+ `[CoderAgentExecutor] Saving final state for task ${taskId}.`,
908
+ );
909
+ try {
910
+ await this.taskStore?.save(wrapper.toSDKTask());
911
+ logger.info(`[CoderAgentExecutor] Task ${taskId} state saved.`);
912
+ } catch (saveError) {
913
+ logger.error(
914
+ `[CoderAgentExecutor] Failed to save task ${taskId} state in finally block:`,
915
+ saveError,
916
+ );
917
+ }
918
+ }
919
+ }
920
+ } catch (error) {
921
+ logger.error(
922
+ `[CoderAgentExecutor] Failed to execute task ${taskId}:`,
923
+ error,
924
+ );
925
+ void pushTaskStateFailed(error, eventBus, taskId, contextId);
926
+ throw error;
927
+ }
928
+ }).catch((error) => {
929
+ logger.error(
930
+ `[CoderAgentExecutor] Failed to initialize isolated environment for task ${taskId}:`,
931
+ error,
932
+ );
933
+ if (eventBus) {
934
+ void pushTaskStateFailed(error, eventBus, taskId, contextId);
935
+ }
936
+ throw error;
937
+ });
938
+ }
939
+ }
packages/a2a-server/src/agent/race-condition.test.ts ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
8
+ import { Task } from './task.js';
9
+ import {
10
+ MessageBusType,
11
+ CoreToolCallStatus,
12
+ type Config,
13
+ type MessageBus,
14
+ } from '@google/gemini-cli-core';
15
+ import { createMockConfig } from '../utils/testing_utils.js';
16
+ import type { RequestContext } from '@a2a-js/sdk/server';
17
+
18
+ describe('Task Race Condition', () => {
19
+ let mockConfig: Config;
20
+ let messageBus: MessageBus;
21
+
22
+ beforeEach(() => {
23
+ messageBus = {
24
+ subscribe: vi.fn(),
25
+ unsubscribe: vi.fn(),
26
+ publish: vi.fn(),
27
+ } as unknown as MessageBus;
28
+ mockConfig = createMockConfig({
29
+ messageBus,
30
+ }) as Config;
31
+ });
32
+
33
+ it('should not hang when multiple tool confirmations are processed while waiting', async () => {
34
+ // @ts-expect-error - private constructor
35
+ const task = new Task('task-id', 'context-id', mockConfig);
36
+
37
+ // 1. Register two tools as scheduled
38
+ task['_registerToolCall']('tool-1', 'scheduled');
39
+ task['_registerToolCall']('tool-2', 'scheduled');
40
+
41
+ // 2. Both transition to awaiting_approval
42
+ const updateHandler = (messageBus.subscribe as Mock).mock.calls.find(
43
+ (c: unknown[]) => c[0] === MessageBusType.TOOL_CALLS_UPDATE,
44
+ )?.[1];
45
+
46
+ updateHandler({
47
+ type: MessageBusType.TOOL_CALLS_UPDATE,
48
+ schedulerId: 'task-id',
49
+ toolCalls: [
50
+ {
51
+ request: { callId: 'tool-1', name: 't1' },
52
+ status: CoreToolCallStatus.AwaitingApproval,
53
+ correlationId: 'corr-1',
54
+ confirmationDetails: { type: 'info' },
55
+ },
56
+ {
57
+ request: { callId: 'tool-2', name: 't2' },
58
+ status: CoreToolCallStatus.AwaitingApproval,
59
+ correlationId: 'corr-2',
60
+ confirmationDetails: { type: 'info' },
61
+ },
62
+ ],
63
+ });
64
+
65
+ // 3. Confirm Tool 1. This makes isAwaitingApprovalOnly() return false.
66
+ for await (const _ of task.acceptUserMessage(
67
+ {
68
+ userMessage: {
69
+ parts: [
70
+ {
71
+ kind: 'data',
72
+ data: { callId: 'tool-1', outcome: 'proceed_once' },
73
+ },
74
+ ],
75
+ },
76
+ } as unknown as RequestContext,
77
+ new AbortController().signal,
78
+ )) {
79
+ // consume generator
80
+ }
81
+
82
+ // 4. Start waiting. This should now block because Tool 1 is confirmed (so we are waiting for its execution).
83
+ const waitPromise = task.waitForPendingTools();
84
+
85
+ // 5. Confirm Tool 2 while waiting.
86
+ for await (const _ of task.acceptUserMessage(
87
+ {
88
+ userMessage: {
89
+ parts: [
90
+ {
91
+ kind: 'data',
92
+ data: { callId: 'tool-2', outcome: 'proceed_once' },
93
+ },
94
+ ],
95
+ },
96
+ } as unknown as RequestContext,
97
+ new AbortController().signal,
98
+ )) {
99
+ // consume generator
100
+ }
101
+
102
+ // 6. Both tools complete successfully
103
+ updateHandler({
104
+ type: MessageBusType.TOOL_CALLS_UPDATE,
105
+ schedulerId: 'task-id',
106
+ toolCalls: [
107
+ {
108
+ request: { callId: 'tool-1', name: 't1' },
109
+ status: CoreToolCallStatus.Success,
110
+ response: { responseParts: [] },
111
+ },
112
+ {
113
+ request: { callId: 'tool-2', name: 't2' },
114
+ status: CoreToolCallStatus.Success,
115
+ response: { responseParts: [] },
116
+ },
117
+ ],
118
+ });
119
+
120
+ // 7. Verify that the original waitPromise resolves.
121
+ await expect(waitPromise).resolves.toBeUndefined();
122
+ });
123
+
124
+ it('should reject waitForPendingTools when tools are cancelled', async () => {
125
+ // @ts-expect-error - private constructor
126
+ const task = new Task('task-id', 'context-id', mockConfig);
127
+
128
+ // 1. Register a tool
129
+ task['_registerToolCall']('tool-1', 'scheduled');
130
+
131
+ // 2. Start waiting
132
+ const waitPromise = task.waitForPendingTools();
133
+
134
+ // 3. Cancel pending tools
135
+ task.cancelPendingTools('User requested cancellation');
136
+
137
+ // 4. Verify waitPromise rejects with the reason
138
+ await expect(waitPromise).rejects.toThrow('User requested cancellation');
139
+ });
140
+
141
+ it('should handle concurrent tool scheduling correctly', async () => {
142
+ // @ts-expect-error - private constructor
143
+ const task = new Task('task-id', 'context-id', mockConfig);
144
+
145
+ // 1. Register a tool and start waiting
146
+ task['_registerToolCall']('tool-1', 'scheduled');
147
+ const waitPromise = task.waitForPendingTools();
148
+
149
+ // 2. Schedule another tool concurrently (e.g. from a secondary user message)
150
+ // This should NOT resolve the current waitPromise until both are done
151
+ await task.scheduleToolCalls(
152
+ [{ callId: 'tool-2', name: 't2', args: {} }],
153
+ new AbortController().signal,
154
+ );
155
+
156
+ expect(task['pendingToolCalls'].size).toBe(2);
157
+
158
+ // 3. Resolve tool 1
159
+ task['_resolveToolCall']('tool-1');
160
+
161
+ // 4. Verify waitPromise is still pending
162
+ let resolved = false;
163
+ waitPromise.then(() => (resolved = true));
164
+ await new Promise((resolve) => setTimeout(resolve, 10));
165
+ expect(resolved).toBe(false);
166
+
167
+ // 5. Resolve tool 2
168
+ task['_resolveToolCall']('tool-2');
169
+
170
+ // 6. Now it should resolve
171
+ await expect(waitPromise).resolves.toBeUndefined();
172
+ });
173
+ });
packages/a2a-server/src/agent/task-event-driven.test.ts ADDED
@@ -0,0 +1,762 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
7
+ import { Task } from './task.js';
8
+ import {
9
+ type Config,
10
+ MessageBusType,
11
+ ToolConfirmationOutcome,
12
+ ApprovalMode,
13
+ Scheduler,
14
+ type MessageBus,
15
+ type ToolLiveOutput,
16
+ } from '@google/gemini-cli-core';
17
+ import { createMockConfig } from '../utils/testing_utils.js';
18
+ import type { ExecutionEventBus } from '@a2a-js/sdk/server';
19
+
20
+ describe('Task Event-Driven Scheduler', () => {
21
+ let mockConfig: Config;
22
+ let mockEventBus: ExecutionEventBus;
23
+ let messageBus: MessageBus;
24
+
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ mockConfig = createMockConfig({
28
+ isEventDrivenSchedulerEnabled: () => true,
29
+ }) as Config;
30
+ messageBus = mockConfig.messageBus;
31
+ mockEventBus = {
32
+ publish: vi.fn(),
33
+ on: vi.fn(),
34
+ off: vi.fn(),
35
+ once: vi.fn(),
36
+ removeAllListeners: vi.fn(),
37
+ finished: vi.fn(),
38
+ };
39
+ });
40
+
41
+ it('should instantiate Scheduler when enabled', () => {
42
+ // @ts-expect-error - Calling private constructor
43
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
44
+ expect(task.scheduler).toBeInstanceOf(Scheduler);
45
+ });
46
+
47
+ it('should subscribe to TOOL_CALLS_UPDATE and map status changes', async () => {
48
+ // @ts-expect-error - Calling private constructor
49
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
50
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
51
+
52
+ const toolCall = {
53
+ request: { callId: '1', name: 'ls', args: {} },
54
+ status: 'executing',
55
+ };
56
+
57
+ // Simulate MessageBus event
58
+ // Simulate MessageBus event
59
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
60
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
61
+ )?.[1];
62
+
63
+ if (!handler) {
64
+ throw new Error('TOOL_CALLS_UPDATE handler not found');
65
+ }
66
+
67
+ handler({
68
+ type: MessageBusType.TOOL_CALLS_UPDATE,
69
+ toolCalls: [toolCall],
70
+ schedulerId: 'task-id',
71
+ });
72
+
73
+ expect(mockEventBus.publish).toHaveBeenCalledWith(
74
+ expect.objectContaining({
75
+ status: expect.objectContaining({
76
+ state: 'submitted', // initial task state
77
+ }),
78
+ metadata: expect.objectContaining({
79
+ coderAgent: expect.objectContaining({
80
+ kind: 'tool-call-update',
81
+ }),
82
+ }),
83
+ }),
84
+ );
85
+ });
86
+
87
+ it('should handle tool confirmations by publishing to MessageBus', async () => {
88
+ // @ts-expect-error - Calling private constructor
89
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
90
+
91
+ const toolCall = {
92
+ request: { callId: '1', name: 'ls', args: {} },
93
+ status: 'awaiting_approval',
94
+ correlationId: 'corr-1',
95
+ confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
96
+ };
97
+
98
+ // Simulate MessageBus event to stash the correlationId
99
+ // Simulate MessageBus event
100
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
101
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
102
+ )?.[1];
103
+
104
+ if (!handler) {
105
+ throw new Error('TOOL_CALLS_UPDATE handler not found');
106
+ }
107
+
108
+ handler({
109
+ type: MessageBusType.TOOL_CALLS_UPDATE,
110
+ toolCalls: [toolCall],
111
+ schedulerId: 'task-id',
112
+ });
113
+
114
+ // Simulate A2A client confirmation
115
+ const part = {
116
+ kind: 'data',
117
+ data: {
118
+ callId: '1',
119
+ outcome: 'proceed_once',
120
+ },
121
+ };
122
+
123
+ const handled = await (
124
+ task as unknown as {
125
+ _handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
126
+ }
127
+ )._handleToolConfirmationPart(part);
128
+ expect(handled).toBe(true);
129
+
130
+ expect(messageBus.publish).toHaveBeenCalledWith(
131
+ expect.objectContaining({
132
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
133
+ correlationId: 'corr-1',
134
+ confirmed: true,
135
+ outcome: ToolConfirmationOutcome.ProceedOnce,
136
+ }),
137
+ );
138
+ });
139
+
140
+ it('should handle Rejection (Cancel) and Modification (ModifyWithEditor)', async () => {
141
+ // @ts-expect-error - Calling private constructor
142
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
143
+
144
+ const toolCall = {
145
+ request: { callId: '1', name: 'ls', args: {} },
146
+ status: 'awaiting_approval',
147
+ correlationId: 'corr-1',
148
+ confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
149
+ };
150
+
151
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
152
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
153
+ )?.[1];
154
+ handler({
155
+ type: MessageBusType.TOOL_CALLS_UPDATE,
156
+ toolCalls: [toolCall],
157
+ schedulerId: 'task-id',
158
+ });
159
+
160
+ // Simulate Rejection (Cancel)
161
+ const handled = await (
162
+ task as unknown as {
163
+ _handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
164
+ }
165
+ )._handleToolConfirmationPart({
166
+ kind: 'data',
167
+ data: { callId: '1', outcome: 'cancel' },
168
+ });
169
+ expect(handled).toBe(true);
170
+ expect(messageBus.publish).toHaveBeenCalledWith(
171
+ expect.objectContaining({
172
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
173
+ correlationId: 'corr-1',
174
+ confirmed: false,
175
+ }),
176
+ );
177
+
178
+ const toolCall2 = {
179
+ request: { callId: '2', name: 'ls', args: {} },
180
+ status: 'awaiting_approval',
181
+ correlationId: 'corr-2',
182
+ confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
183
+ };
184
+ handler({
185
+ type: MessageBusType.TOOL_CALLS_UPDATE,
186
+ toolCalls: [toolCall2],
187
+ schedulerId: 'task-id',
188
+ });
189
+
190
+ // Simulate ModifyWithEditor
191
+ const handled2 = await (
192
+ task as unknown as {
193
+ _handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
194
+ }
195
+ )._handleToolConfirmationPart({
196
+ kind: 'data',
197
+ data: { callId: '2', outcome: 'modify_with_editor' },
198
+ });
199
+ expect(handled2).toBe(true);
200
+ expect(messageBus.publish).toHaveBeenCalledWith(
201
+ expect.objectContaining({
202
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
203
+ correlationId: 'corr-2',
204
+ confirmed: false,
205
+ outcome: ToolConfirmationOutcome.ModifyWithEditor,
206
+ payload: undefined,
207
+ }),
208
+ );
209
+ });
210
+
211
+ it('should handle MCP Server tool operations correctly', async () => {
212
+ // @ts-expect-error - Calling private constructor
213
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
214
+
215
+ const toolCall = {
216
+ request: { callId: '1', name: 'call_mcp_tool', args: {} },
217
+ status: 'awaiting_approval',
218
+ correlationId: 'corr-mcp-1',
219
+ confirmationDetails: {
220
+ type: 'mcp',
221
+ title: 'MCP Server Operation',
222
+ prompt: 'test_mcp',
223
+ },
224
+ };
225
+
226
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
227
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
228
+ )?.[1];
229
+ handler({
230
+ type: MessageBusType.TOOL_CALLS_UPDATE,
231
+ toolCalls: [toolCall],
232
+ schedulerId: 'task-id',
233
+ });
234
+
235
+ // Simulate ProceedOnce for MCP
236
+ const handled = await (
237
+ task as unknown as {
238
+ _handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
239
+ }
240
+ )._handleToolConfirmationPart({
241
+ kind: 'data',
242
+ data: { callId: '1', outcome: 'proceed_once' },
243
+ });
244
+ expect(handled).toBe(true);
245
+ expect(messageBus.publish).toHaveBeenCalledWith(
246
+ expect.objectContaining({
247
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
248
+ correlationId: 'corr-mcp-1',
249
+ confirmed: true,
250
+ outcome: ToolConfirmationOutcome.ProceedOnce,
251
+ }),
252
+ );
253
+ });
254
+
255
+ it('should handle MCP Server tool ProceedAlwaysServer outcome', async () => {
256
+ // @ts-expect-error - Calling private constructor
257
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
258
+
259
+ const toolCall = {
260
+ request: { callId: '1', name: 'call_mcp_tool', args: {} },
261
+ status: 'awaiting_approval',
262
+ correlationId: 'corr-mcp-2',
263
+ confirmationDetails: {
264
+ type: 'mcp',
265
+ title: 'MCP Server Operation',
266
+ prompt: 'test_mcp',
267
+ },
268
+ };
269
+
270
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
271
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
272
+ )?.[1];
273
+ handler({
274
+ type: MessageBusType.TOOL_CALLS_UPDATE,
275
+ toolCalls: [toolCall],
276
+ schedulerId: 'task-id',
277
+ });
278
+
279
+ const handled = await (
280
+ task as unknown as {
281
+ _handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
282
+ }
283
+ )._handleToolConfirmationPart({
284
+ kind: 'data',
285
+ data: { callId: '1', outcome: 'proceed_always_server' },
286
+ });
287
+ expect(handled).toBe(true);
288
+ expect(messageBus.publish).toHaveBeenCalledWith(
289
+ expect.objectContaining({
290
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
291
+ correlationId: 'corr-mcp-2',
292
+ confirmed: true,
293
+ outcome: ToolConfirmationOutcome.ProceedAlwaysServer,
294
+ }),
295
+ );
296
+ });
297
+
298
+ it('should handle MCP Server tool ProceedAlwaysTool outcome', async () => {
299
+ // @ts-expect-error - Calling private constructor
300
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
301
+
302
+ const toolCall = {
303
+ request: { callId: '1', name: 'call_mcp_tool', args: {} },
304
+ status: 'awaiting_approval',
305
+ correlationId: 'corr-mcp-3',
306
+ confirmationDetails: {
307
+ type: 'mcp',
308
+ title: 'MCP Server Operation',
309
+ prompt: 'test_mcp',
310
+ },
311
+ };
312
+
313
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
314
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
315
+ )?.[1];
316
+ handler({
317
+ type: MessageBusType.TOOL_CALLS_UPDATE,
318
+ toolCalls: [toolCall],
319
+ schedulerId: 'task-id',
320
+ });
321
+
322
+ const handled = await (
323
+ task as unknown as {
324
+ _handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
325
+ }
326
+ )._handleToolConfirmationPart({
327
+ kind: 'data',
328
+ data: { callId: '1', outcome: 'proceed_always_tool' },
329
+ });
330
+ expect(handled).toBe(true);
331
+ expect(messageBus.publish).toHaveBeenCalledWith(
332
+ expect.objectContaining({
333
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
334
+ correlationId: 'corr-mcp-3',
335
+ confirmed: true,
336
+ outcome: ToolConfirmationOutcome.ProceedAlwaysTool,
337
+ }),
338
+ );
339
+ });
340
+
341
+ it('should handle MCP Server tool ProceedAlwaysAndSave outcome', async () => {
342
+ // @ts-expect-error - Calling private constructor
343
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
344
+
345
+ const toolCall = {
346
+ request: { callId: '1', name: 'call_mcp_tool', args: {} },
347
+ status: 'awaiting_approval',
348
+ correlationId: 'corr-mcp-4',
349
+ confirmationDetails: {
350
+ type: 'mcp',
351
+ title: 'MCP Server Operation',
352
+ prompt: 'test_mcp',
353
+ },
354
+ };
355
+
356
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
357
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
358
+ )?.[1];
359
+ handler({
360
+ type: MessageBusType.TOOL_CALLS_UPDATE,
361
+ toolCalls: [toolCall],
362
+ schedulerId: 'task-id',
363
+ });
364
+
365
+ const handled = await (
366
+ task as unknown as {
367
+ _handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
368
+ }
369
+ )._handleToolConfirmationPart({
370
+ kind: 'data',
371
+ data: { callId: '1', outcome: 'proceed_always_and_save' },
372
+ });
373
+ expect(handled).toBe(true);
374
+ expect(messageBus.publish).toHaveBeenCalledWith(
375
+ expect.objectContaining({
376
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
377
+ correlationId: 'corr-mcp-4',
378
+ confirmed: true,
379
+ outcome: ToolConfirmationOutcome.ProceedAlwaysAndSave,
380
+ }),
381
+ );
382
+ });
383
+
384
+ it('should execute without confirmation in YOLO mode and not transition to input-required', async () => {
385
+ // Enable YOLO mode
386
+ const yoloConfig = createMockConfig({
387
+ isEventDrivenSchedulerEnabled: () => true,
388
+ getApprovalMode: () => ApprovalMode.YOLO,
389
+ }) as Config;
390
+ const yoloMessageBus = yoloConfig.messageBus;
391
+
392
+ // @ts-expect-error - Calling private constructor
393
+ const task = new Task('task-id', 'context-id', yoloConfig, mockEventBus);
394
+ task.setTaskStateAndPublishUpdate = vi.fn();
395
+
396
+ const toolCall = {
397
+ request: { callId: '1', name: 'ls', args: {} },
398
+ status: 'awaiting_approval',
399
+ correlationId: 'corr-1',
400
+ confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
401
+ };
402
+
403
+ const handler = (yoloMessageBus.subscribe as Mock).mock.calls.find(
404
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
405
+ )?.[1];
406
+ handler({
407
+ type: MessageBusType.TOOL_CALLS_UPDATE,
408
+ toolCalls: [toolCall],
409
+ schedulerId: 'task-id',
410
+ });
411
+
412
+ // Should NOT auto-publish ProceedOnce anymore, because PolicyEngine handles it directly
413
+ expect(yoloMessageBus.publish).not.toHaveBeenCalledWith(
414
+ expect.objectContaining({
415
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
416
+ }),
417
+ );
418
+
419
+ // Should NOT transition to input-required since it was auto-approved
420
+ expect(task.setTaskStateAndPublishUpdate).not.toHaveBeenCalledWith(
421
+ 'input-required',
422
+ expect.anything(),
423
+ undefined,
424
+ undefined,
425
+ true,
426
+ );
427
+ });
428
+
429
+ it('should handle output updates via the message bus', async () => {
430
+ // @ts-expect-error - Calling private constructor
431
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
432
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
433
+
434
+ const toolCall = {
435
+ request: { callId: '1', name: 'ls', args: {} },
436
+ status: 'executing',
437
+ liveOutput: 'chunk1',
438
+ };
439
+
440
+ // Simulate MessageBus event
441
+ // Simulate MessageBus event
442
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
443
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
444
+ )?.[1];
445
+
446
+ if (!handler) {
447
+ throw new Error('TOOL_CALLS_UPDATE handler not found');
448
+ }
449
+
450
+ handler({
451
+ type: MessageBusType.TOOL_CALLS_UPDATE,
452
+ toolCalls: [toolCall],
453
+ schedulerId: 'task-id',
454
+ });
455
+
456
+ // Should publish artifact update for output
457
+ expect(mockEventBus.publish).toHaveBeenCalledWith(
458
+ expect.objectContaining({
459
+ kind: 'artifact-update',
460
+ artifact: expect.objectContaining({
461
+ artifactId: 'tool-1-output',
462
+ parts: [{ kind: 'text', text: 'chunk1' }],
463
+ }),
464
+ }),
465
+ );
466
+ });
467
+
468
+ it('should complete artifact creation without hanging', async () => {
469
+ // @ts-expect-error - Calling private constructor
470
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
471
+
472
+ const toolCallId = 'create-file-123';
473
+ task['_registerToolCall'](toolCallId, 'executing');
474
+
475
+ const toolCall = {
476
+ request: {
477
+ callId: toolCallId,
478
+ name: 'writeFile',
479
+ args: { path: 'test.sh' },
480
+ },
481
+ status: 'success',
482
+ result: { ok: true },
483
+ };
484
+
485
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
486
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
487
+ )?.[1];
488
+ handler({
489
+ type: MessageBusType.TOOL_CALLS_UPDATE,
490
+ toolCalls: [toolCall],
491
+ schedulerId: 'task-id',
492
+ });
493
+
494
+ // The tool should be complete and registered appropriately, eventually
495
+ // triggering the toolCompletionPromise resolution when all clear.
496
+ const internalTask = task as unknown as {
497
+ completedToolCalls: unknown[];
498
+ pendingToolCalls: Map<string, string>;
499
+ };
500
+ expect(internalTask.completedToolCalls.length).toBe(1);
501
+ expect(internalTask.pendingToolCalls.size).toBe(0);
502
+ });
503
+
504
+ it('should preserve messageId across multiple text chunks to prevent UI duplication', async () => {
505
+ // @ts-expect-error - Calling private constructor
506
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
507
+
508
+ // Initialize the ID for the first turn (happens internally upon LLM stream)
509
+ task.currentAgentMessageId = 'test-id-123';
510
+
511
+ // Simulate sending multiple text chunks
512
+ task._sendTextContent('chunk 1');
513
+ task._sendTextContent('chunk 2');
514
+
515
+ // Both text contents should have been published with the same messageId
516
+ const textCalls = (mockEventBus.publish as Mock).mock.calls.filter(
517
+ (call) => call[0].status?.message?.kind === 'message',
518
+ );
519
+ expect(textCalls.length).toBe(2);
520
+ expect(textCalls[0][0].status.message.messageId).toBe('test-id-123');
521
+ expect(textCalls[1][0].status.message.messageId).toBe('test-id-123');
522
+
523
+ // Simulate starting a new turn by calling getAndClearCompletedTools
524
+ // (which precedes sendCompletedToolsToLlm where a new ID is minted)
525
+ task.getAndClearCompletedTools();
526
+
527
+ // sendCompletedToolsToLlm internally rolls the ID forward.
528
+ // Simulate what sendCompletedToolsToLlm does:
529
+ const internalTask = task as unknown as {
530
+ setTaskStateAndPublishUpdate: (state: string, change: unknown) => void;
531
+ };
532
+ internalTask.setTaskStateAndPublishUpdate('working', {});
533
+
534
+ // Simulate what sendCompletedToolsToLlm does: generate a new UUID for the next turn
535
+ task.currentAgentMessageId = 'test-id-456';
536
+
537
+ task._sendTextContent('chunk 3');
538
+
539
+ const secondTurnCalls = (mockEventBus.publish as Mock).mock.calls.filter(
540
+ (call) => call[0].status?.message?.messageId === 'test-id-456',
541
+ );
542
+ expect(secondTurnCalls.length).toBe(1);
543
+ expect(secondTurnCalls[0][0].status.message.parts[0].text).toBe('chunk 3');
544
+ });
545
+
546
+ it('should handle parallel tool calls correctly', async () => {
547
+ // @ts-expect-error - Calling private constructor
548
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
549
+
550
+ const toolCall1 = {
551
+ request: { callId: '1', name: 'ls', args: {} },
552
+ status: 'awaiting_approval',
553
+ correlationId: 'corr-1',
554
+ confirmationDetails: { type: 'info', title: 'test 1', prompt: 'test 1' },
555
+ };
556
+
557
+ const toolCall2 = {
558
+ request: { callId: '2', name: 'pwd', args: {} },
559
+ status: 'awaiting_approval',
560
+ correlationId: 'corr-2',
561
+ confirmationDetails: { type: 'info', title: 'test 2', prompt: 'test 2' },
562
+ };
563
+
564
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
565
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
566
+ )?.[1];
567
+
568
+ // Publish update for both tool calls simultaneously
569
+ handler({
570
+ type: MessageBusType.TOOL_CALLS_UPDATE,
571
+ toolCalls: [toolCall1, toolCall2],
572
+ schedulerId: 'task-id',
573
+ });
574
+
575
+ // Confirm first tool call
576
+ const handled1 = await (
577
+ task as unknown as {
578
+ _handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
579
+ }
580
+ )._handleToolConfirmationPart({
581
+ kind: 'data',
582
+ data: { callId: '1', outcome: 'proceed_once' },
583
+ });
584
+ expect(handled1).toBe(true);
585
+ expect(messageBus.publish).toHaveBeenCalledWith(
586
+ expect.objectContaining({
587
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
588
+ correlationId: 'corr-1',
589
+ confirmed: true,
590
+ }),
591
+ );
592
+
593
+ // Confirm second tool call
594
+ const handled2 = await (
595
+ task as unknown as {
596
+ _handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
597
+ }
598
+ )._handleToolConfirmationPart({
599
+ kind: 'data',
600
+ data: { callId: '2', outcome: 'cancel' },
601
+ });
602
+ expect(handled2).toBe(true);
603
+ expect(messageBus.publish).toHaveBeenCalledWith(
604
+ expect.objectContaining({
605
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
606
+ correlationId: 'corr-2',
607
+ confirmed: false,
608
+ }),
609
+ );
610
+ });
611
+
612
+ it('should handle multi-turn tool resolution correctly', async () => {
613
+ // @ts-expect-error - Calling private constructor
614
+ const task = new Task('task-id', 'context-id', mockConfig);
615
+
616
+ task['_registerToolCall']('1', 'scheduled');
617
+ task['_registerToolCall']('2', 'scheduled');
618
+
619
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
620
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
621
+ )?.[1];
622
+
623
+ // Turn 1: Resolve tool 1
624
+ handler({
625
+ type: MessageBusType.TOOL_CALLS_UPDATE,
626
+ toolCalls: [
627
+ {
628
+ request: { callId: '1', name: 't1' },
629
+ status: 'success',
630
+ response: { responseParts: [] },
631
+ },
632
+ ],
633
+ schedulerId: 'task-id',
634
+ });
635
+
636
+ expect(task['pendingToolCalls'].size).toBe(1);
637
+ expect(task['pendingToolCalls'].has('2')).toBe(true);
638
+
639
+ // Turn 2: Resolve tool 2
640
+ handler({
641
+ type: MessageBusType.TOOL_CALLS_UPDATE,
642
+ toolCalls: [
643
+ {
644
+ request: { callId: '2', name: 't2' },
645
+ status: 'success',
646
+ response: { responseParts: [] },
647
+ },
648
+ ],
649
+ schedulerId: 'task-id',
650
+ });
651
+
652
+ expect(task['pendingToolCalls'].size).toBe(0);
653
+ });
654
+
655
+ it('should handle subagent progress events from the scheduler', async () => {
656
+ // @ts-expect-error - Calling private constructor
657
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
658
+
659
+ // Trigger _schedulerOutputUpdate with subagent progress
660
+ task['_schedulerOutputUpdate']('tool-1', {
661
+ isSubagentProgress: true,
662
+ agentName: 'researcher',
663
+ recentActivity: [],
664
+ } as ToolLiveOutput);
665
+
666
+ expect(mockEventBus.publish).toHaveBeenCalledWith(
667
+ expect.objectContaining({
668
+ kind: 'artifact-update',
669
+ artifact: expect.objectContaining({
670
+ parts: [
671
+ expect.objectContaining({
672
+ text: expect.stringContaining('researcher'),
673
+ }),
674
+ ],
675
+ }),
676
+ }),
677
+ );
678
+ });
679
+
680
+ it('should wait for executing tools before transitioning to input-required state', async () => {
681
+ // @ts-expect-error - Calling private constructor
682
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
683
+
684
+ task.setTaskStateAndPublishUpdate = vi.fn();
685
+
686
+ // Register tool 1 as executing
687
+ task['_registerToolCall']('1', 'executing');
688
+
689
+ const toolCall1 = {
690
+ request: { callId: '1', name: 'ls', args: {} },
691
+ status: 'executing',
692
+ };
693
+
694
+ const toolCall2 = {
695
+ request: { callId: '2', name: 'pwd', args: {} },
696
+ status: 'awaiting_approval',
697
+ correlationId: 'corr-2',
698
+ confirmationDetails: { type: 'info', title: 'test 2', prompt: 'test 2' },
699
+ };
700
+
701
+ const handler = (messageBus.subscribe as Mock).mock.calls.find(
702
+ (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
703
+ )?.[1];
704
+
705
+ handler({
706
+ type: MessageBusType.TOOL_CALLS_UPDATE,
707
+ toolCalls: [toolCall1, toolCall2],
708
+ schedulerId: 'task-id',
709
+ });
710
+
711
+ // Should NOT transition to input-required yet
712
+ expect(task.setTaskStateAndPublishUpdate).not.toHaveBeenCalledWith(
713
+ 'input-required',
714
+ expect.anything(),
715
+ undefined,
716
+ undefined,
717
+ true,
718
+ );
719
+
720
+ // Complete tool 1
721
+ const toolCall1Complete = {
722
+ ...toolCall1,
723
+ status: 'success',
724
+ result: { ok: true },
725
+ };
726
+
727
+ handler({
728
+ type: MessageBusType.TOOL_CALLS_UPDATE,
729
+ toolCalls: [toolCall1Complete, toolCall2],
730
+ schedulerId: 'task-id',
731
+ });
732
+
733
+ // Now it should transition
734
+ expect(task.setTaskStateAndPublishUpdate).toHaveBeenCalledWith(
735
+ 'input-required',
736
+ expect.anything(),
737
+ undefined,
738
+ undefined,
739
+ true,
740
+ );
741
+ });
742
+
743
+ it('should ignore confirmations for unknown tool calls', async () => {
744
+ // @ts-expect-error - Calling private constructor
745
+ const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
746
+
747
+ const handled = await (
748
+ task as unknown as {
749
+ _handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
750
+ }
751
+ )._handleToolConfirmationPart({
752
+ kind: 'data',
753
+ data: { callId: 'unknown-id', outcome: 'proceed_once' },
754
+ });
755
+
756
+ // Should return false for unhandled tool call
757
+ expect(handled).toBe(false);
758
+
759
+ // Should not publish anything to the message bus
760
+ expect(messageBus.publish).not.toHaveBeenCalled();
761
+ });
762
+ });
packages/a2a-server/src/agent/task.test.ts ADDED
@@ -0,0 +1,916 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
8
+ import { Task } from './task.js';
9
+ import {
10
+ GeminiEventType,
11
+ type Config,
12
+ type ToolCallRequestInfo,
13
+ type GitService,
14
+ type CompletedToolCall,
15
+ type ToolCall,
16
+ type ToolCallsUpdateMessage,
17
+ MessageBusType,
18
+ } from '@google/gemini-cli-core';
19
+ import { createMockConfig } from '../utils/testing_utils.js';
20
+ import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
21
+ import { CoderAgentEvent } from '../types.js';
22
+
23
+ const mockProcessRestorableToolCalls = vi.hoisted(() => vi.fn());
24
+
25
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
26
+ const original =
27
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
28
+ return {
29
+ ...original,
30
+ processRestorableToolCalls: mockProcessRestorableToolCalls,
31
+ };
32
+ });
33
+
34
+ describe('Task', () => {
35
+ it('scheduleToolCalls should not modify the input requests array', async () => {
36
+ const mockConfig = createMockConfig();
37
+
38
+ const mockEventBus: ExecutionEventBus = {
39
+ publish: vi.fn(),
40
+ on: vi.fn(),
41
+ off: vi.fn(),
42
+ once: vi.fn(),
43
+ removeAllListeners: vi.fn(),
44
+ finished: vi.fn(),
45
+ };
46
+
47
+ // The Task constructor is private. We'll bypass it for this unit test.
48
+ // @ts-expect-error - Calling private constructor for test purposes.
49
+ const task = new Task(
50
+ 'task-id',
51
+ 'context-id',
52
+ mockConfig as Config,
53
+ mockEventBus,
54
+ );
55
+
56
+ task['setTaskStateAndPublishUpdate'] = vi.fn();
57
+ task['getProposedContent'] = vi.fn().mockResolvedValue('new content');
58
+
59
+ const requests: ToolCallRequestInfo[] = [
60
+ {
61
+ callId: '1',
62
+ name: 'replace',
63
+ args: {
64
+ file_path: 'test.txt',
65
+ old_string: 'old',
66
+ new_string: 'new',
67
+ },
68
+ isClientInitiated: false,
69
+ prompt_id: 'prompt-id-1',
70
+ },
71
+ ];
72
+
73
+ const originalRequests = JSON.parse(JSON.stringify(requests));
74
+ const abortController = new AbortController();
75
+
76
+ await task.scheduleToolCalls(requests, abortController.signal);
77
+
78
+ expect(requests).toEqual(originalRequests);
79
+ });
80
+
81
+ describe('scheduleToolCalls', () => {
82
+ const mockConfig = createMockConfig();
83
+ const mockEventBus: ExecutionEventBus = {
84
+ publish: vi.fn(),
85
+ on: vi.fn(),
86
+ off: vi.fn(),
87
+ once: vi.fn(),
88
+ removeAllListeners: vi.fn(),
89
+ finished: vi.fn(),
90
+ };
91
+
92
+ beforeEach(() => {
93
+ vi.clearAllMocks();
94
+ });
95
+
96
+ it('should not create a checkpoint if no restorable tools are called', async () => {
97
+ // @ts-expect-error - Calling private constructor for test purposes.
98
+ const task = new Task(
99
+ 'task-id',
100
+ 'context-id',
101
+ mockConfig as Config,
102
+ mockEventBus,
103
+ );
104
+ const requests: ToolCallRequestInfo[] = [
105
+ {
106
+ callId: '1',
107
+ name: 'run_shell_command',
108
+ args: { command: 'ls' },
109
+ isClientInitiated: false,
110
+ prompt_id: 'prompt-id-1',
111
+ },
112
+ ];
113
+ const abortController = new AbortController();
114
+ await task.scheduleToolCalls(requests, abortController.signal);
115
+ expect(mockProcessRestorableToolCalls).not.toHaveBeenCalled();
116
+ });
117
+
118
+ it('should create a checkpoint if a restorable tool is called', async () => {
119
+ const mockConfig = createMockConfig({
120
+ getCheckpointingEnabled: () => true,
121
+ getGitService: () => Promise.resolve({} as GitService),
122
+ });
123
+ mockProcessRestorableToolCalls.mockResolvedValue({
124
+ checkpointsToWrite: new Map([['test.json', 'test content']]),
125
+ toolCallToCheckpointMap: new Map(),
126
+ errors: [],
127
+ });
128
+ // @ts-expect-error - Calling private constructor for test purposes.
129
+ const task = new Task(
130
+ 'task-id',
131
+ 'context-id',
132
+ mockConfig as Config,
133
+ mockEventBus,
134
+ );
135
+ const requests: ToolCallRequestInfo[] = [
136
+ {
137
+ callId: '1',
138
+ name: 'replace',
139
+ args: {
140
+ file_path: 'test.txt',
141
+ old_string: 'old',
142
+ new_string: 'new',
143
+ },
144
+ isClientInitiated: false,
145
+ prompt_id: 'prompt-id-1',
146
+ },
147
+ ];
148
+ const abortController = new AbortController();
149
+ await task.scheduleToolCalls(requests, abortController.signal);
150
+ expect(mockProcessRestorableToolCalls).toHaveBeenCalledOnce();
151
+ });
152
+
153
+ it('should process all restorable tools for checkpointing in a single batch', async () => {
154
+ const mockConfig = createMockConfig({
155
+ getCheckpointingEnabled: () => true,
156
+ getGitService: () => Promise.resolve({} as GitService),
157
+ });
158
+ mockProcessRestorableToolCalls.mockResolvedValue({
159
+ checkpointsToWrite: new Map([
160
+ ['test1.json', 'test content 1'],
161
+ ['test2.json', 'test content 2'],
162
+ ]),
163
+ toolCallToCheckpointMap: new Map([
164
+ ['1', 'test1'],
165
+ ['2', 'test2'],
166
+ ]),
167
+ errors: [],
168
+ });
169
+ // @ts-expect-error - Calling private constructor for test purposes.
170
+ const task = new Task(
171
+ 'task-id',
172
+ 'context-id',
173
+ mockConfig as Config,
174
+ mockEventBus,
175
+ );
176
+ const requests: ToolCallRequestInfo[] = [
177
+ {
178
+ callId: '1',
179
+ name: 'replace',
180
+ args: {
181
+ file_path: 'test.txt',
182
+ old_string: 'old',
183
+ new_string: 'new',
184
+ },
185
+ isClientInitiated: false,
186
+ prompt_id: 'prompt-id-1',
187
+ },
188
+ {
189
+ callId: '2',
190
+ name: 'write_file',
191
+ args: { file_path: 'test2.txt', content: 'new content' },
192
+ isClientInitiated: false,
193
+ prompt_id: 'prompt-id-2',
194
+ },
195
+ {
196
+ callId: '3',
197
+ name: 'not_restorable',
198
+ args: {},
199
+ isClientInitiated: false,
200
+ prompt_id: 'prompt-id-3',
201
+ },
202
+ ];
203
+ const abortController = new AbortController();
204
+ await task.scheduleToolCalls(requests, abortController.signal);
205
+ expect(mockProcessRestorableToolCalls).toHaveBeenCalledExactlyOnceWith(
206
+ [
207
+ expect.objectContaining({ callId: '1' }),
208
+ expect.objectContaining({ callId: '2' }),
209
+ ],
210
+ expect.anything(),
211
+ expect.anything(),
212
+ );
213
+ });
214
+ });
215
+
216
+ describe('acceptAgentMessage', () => {
217
+ it('should set currentTraceId when event has traceId', async () => {
218
+ const mockConfig = createMockConfig();
219
+ const mockEventBus: ExecutionEventBus = {
220
+ publish: vi.fn(),
221
+ on: vi.fn(),
222
+ off: vi.fn(),
223
+ once: vi.fn(),
224
+ removeAllListeners: vi.fn(),
225
+ finished: vi.fn(),
226
+ };
227
+
228
+ // @ts-expect-error - Calling private constructor for test purposes.
229
+ const task = new Task(
230
+ 'task-id',
231
+ 'context-id',
232
+ mockConfig as Config,
233
+ mockEventBus,
234
+ );
235
+
236
+ const event = {
237
+ type: 'content',
238
+ value: 'test',
239
+ traceId: 'test-trace-id',
240
+ };
241
+
242
+ await task.acceptAgentMessage(event);
243
+
244
+ expect(mockEventBus.publish).toHaveBeenCalledWith(
245
+ expect.objectContaining({
246
+ metadata: expect.objectContaining({
247
+ traceId: 'test-trace-id',
248
+ }),
249
+ }),
250
+ );
251
+ });
252
+
253
+ it('should handle Citation event and publish to event bus', async () => {
254
+ const mockConfig = createMockConfig();
255
+ const mockEventBus: ExecutionEventBus = {
256
+ publish: vi.fn(),
257
+ on: vi.fn(),
258
+ off: vi.fn(),
259
+ once: vi.fn(),
260
+ removeAllListeners: vi.fn(),
261
+ finished: vi.fn(),
262
+ };
263
+
264
+ // @ts-expect-error - Calling private constructor for test purposes.
265
+ const task = new Task(
266
+ 'task-id',
267
+ 'context-id',
268
+ mockConfig as Config,
269
+ mockEventBus,
270
+ );
271
+
272
+ const citationText = 'Source: example.com';
273
+ const citationEvent = {
274
+ type: GeminiEventType.Citation,
275
+ value: citationText,
276
+ };
277
+
278
+ await task.acceptAgentMessage(citationEvent);
279
+
280
+ expect(mockEventBus.publish).toHaveBeenCalledOnce();
281
+ const publishedEvent = (mockEventBus.publish as Mock).mock.calls[0][0];
282
+
283
+ expect(publishedEvent.kind).toBe('status-update');
284
+ expect(publishedEvent.taskId).toBe('task-id');
285
+ expect(publishedEvent.metadata.coderAgent.kind).toBe(
286
+ CoderAgentEvent.CitationEvent,
287
+ );
288
+ expect(publishedEvent.status.message).toBeDefined();
289
+ expect(publishedEvent.status.message.parts).toEqual([
290
+ {
291
+ kind: 'text',
292
+ text: citationText,
293
+ },
294
+ ]);
295
+ });
296
+
297
+ it('should capture usageMetadata on Finished event and include it in final status update', async () => {
298
+ const mockConfig = createMockConfig();
299
+ const mockEventBus: ExecutionEventBus = {
300
+ publish: vi.fn(),
301
+ on: vi.fn(),
302
+ off: vi.fn(),
303
+ once: vi.fn(),
304
+ removeAllListeners: vi.fn(),
305
+ finished: vi.fn(),
306
+ };
307
+
308
+ // @ts-expect-error - Calling private constructor for test purposes.
309
+ const task = new Task(
310
+ 'task-id',
311
+ 'context-id',
312
+ mockConfig as Config,
313
+ mockEventBus,
314
+ );
315
+
316
+ const finishedEvent = {
317
+ type: GeminiEventType.Finished,
318
+ value: {
319
+ reason: 'STOP',
320
+ usageMetadata: {
321
+ promptTokenCount: 100,
322
+ candidatesTokenCount: 50,
323
+ totalTokenCount: 150,
324
+ },
325
+ },
326
+ };
327
+
328
+ await task.acceptAgentMessage(finishedEvent);
329
+ expect(task.usageMetadata).toEqual({
330
+ promptTokenCount: 100,
331
+ candidatesTokenCount: 50,
332
+ totalTokenCount: 150,
333
+ });
334
+
335
+ task.setTaskStateAndPublishUpdate(
336
+ 'input-required',
337
+ { kind: CoderAgentEvent.StateChangeEvent },
338
+ undefined,
339
+ undefined,
340
+ true, // final
341
+ );
342
+
343
+ expect(mockEventBus.publish).toHaveBeenCalledWith(
344
+ expect.objectContaining({
345
+ final: true,
346
+ metadata: expect.objectContaining({
347
+ usageMetadata: {
348
+ promptTokenCount: 100,
349
+ candidatesTokenCount: 50,
350
+ totalTokenCount: 150,
351
+ },
352
+ }),
353
+ }),
354
+ );
355
+ });
356
+
357
+ it('should update modelInfo and reflect it in metadata and status updates', async () => {
358
+ const mockConfig = createMockConfig();
359
+ const mockEventBus: ExecutionEventBus = {
360
+ publish: vi.fn(),
361
+ on: vi.fn(),
362
+ off: vi.fn(),
363
+ once: vi.fn(),
364
+ removeAllListeners: vi.fn(),
365
+ finished: vi.fn(),
366
+ };
367
+
368
+ // @ts-expect-error - Calling private constructor for test purposes.
369
+ const task = new Task(
370
+ 'task-id',
371
+ 'context-id',
372
+ mockConfig as Config,
373
+ mockEventBus,
374
+ );
375
+
376
+ const modelInfoEvent = {
377
+ type: GeminiEventType.ModelInfo,
378
+ value: 'new-model-name',
379
+ };
380
+
381
+ await task.acceptAgentMessage(modelInfoEvent);
382
+
383
+ expect(task.modelInfo).toBe('new-model-name');
384
+
385
+ // Check getMetadata
386
+ const metadata = await task.getMetadata();
387
+ expect(metadata.model).toBe('new-model-name');
388
+
389
+ // Check status update
390
+ task.setTaskStateAndPublishUpdate(
391
+ 'working',
392
+ { kind: CoderAgentEvent.StateChangeEvent },
393
+ 'Working...',
394
+ );
395
+
396
+ expect(mockEventBus.publish).toHaveBeenCalledWith(
397
+ expect.objectContaining({
398
+ metadata: expect.objectContaining({
399
+ model: 'new-model-name',
400
+ }),
401
+ }),
402
+ );
403
+ });
404
+
405
+ it.each([
406
+ { eventType: GeminiEventType.Retry, eventName: 'Retry' },
407
+ { eventType: GeminiEventType.InvalidStream, eventName: 'InvalidStream' },
408
+ ])(
409
+ 'should handle $eventName event without triggering error handling',
410
+ async ({ eventType }) => {
411
+ const mockConfig = createMockConfig();
412
+ const mockEventBus: ExecutionEventBus = {
413
+ publish: vi.fn(),
414
+ on: vi.fn(),
415
+ off: vi.fn(),
416
+ once: vi.fn(),
417
+ removeAllListeners: vi.fn(),
418
+ finished: vi.fn(),
419
+ };
420
+
421
+ // @ts-expect-error - Calling private constructor
422
+ const task = new Task(
423
+ 'task-id',
424
+ 'context-id',
425
+ mockConfig as Config,
426
+ mockEventBus,
427
+ );
428
+
429
+ const cancelPendingToolsSpy = vi.spyOn(task, 'cancelPendingTools');
430
+ const setTaskStateSpy = vi.spyOn(task, 'setTaskStateAndPublishUpdate');
431
+
432
+ const event = {
433
+ type: eventType,
434
+ };
435
+
436
+ await task.acceptAgentMessage(event);
437
+
438
+ expect(cancelPendingToolsSpy).not.toHaveBeenCalled();
439
+ expect(setTaskStateSpy).not.toHaveBeenCalled();
440
+ },
441
+ );
442
+ });
443
+
444
+ describe('currentPromptId and promptCount', () => {
445
+ it('should correctly initialize and update promptId and promptCount', async () => {
446
+ const mockConfig = createMockConfig();
447
+ mockConfig.getGeminiClient = vi.fn().mockReturnValue({
448
+ sendMessageStream: vi.fn().mockReturnValue((async function* () {})()),
449
+ });
450
+ mockConfig.getSessionId = () => 'test-session-id';
451
+
452
+ const mockEventBus: ExecutionEventBus = {
453
+ publish: vi.fn(),
454
+ on: vi.fn(),
455
+ off: vi.fn(),
456
+ once: vi.fn(),
457
+ removeAllListeners: vi.fn(),
458
+ finished: vi.fn(),
459
+ };
460
+
461
+ // @ts-expect-error - Calling private constructor
462
+ const task = new Task(
463
+ 'task-id',
464
+ 'context-id',
465
+ mockConfig as Config,
466
+ mockEventBus,
467
+ );
468
+
469
+ // Initial state
470
+ expect(task.currentPromptId).toBeUndefined();
471
+ expect(task.promptCount).toBe(0);
472
+
473
+ // First user message should set prompt_id
474
+ const userMessage1 = {
475
+ userMessage: {
476
+ parts: [{ kind: 'text', text: 'hello' }],
477
+ },
478
+ } as RequestContext;
479
+ const abortController1 = new AbortController();
480
+ for await (const _ of task.acceptUserMessage(
481
+ userMessage1,
482
+ abortController1.signal,
483
+ )) {
484
+ // no-op
485
+ }
486
+
487
+ const expectedPromptId1 = 'test-session-id########0';
488
+ expect(task.promptCount).toBe(1);
489
+ expect(task.currentPromptId).toBe(expectedPromptId1);
490
+
491
+ // A new user message should generate a new prompt_id
492
+ const userMessage2 = {
493
+ userMessage: {
494
+ parts: [{ kind: 'text', text: 'world' }],
495
+ },
496
+ } as RequestContext;
497
+ const abortController2 = new AbortController();
498
+ for await (const _ of task.acceptUserMessage(
499
+ userMessage2,
500
+ abortController2.signal,
501
+ )) {
502
+ // no-op
503
+ }
504
+
505
+ const expectedPromptId2 = 'test-session-id########1';
506
+ expect(task.promptCount).toBe(2);
507
+ expect(task.currentPromptId).toBe(expectedPromptId2);
508
+
509
+ // Subsequent tool call processing should use the same prompt_id
510
+ const completedTool = {
511
+ request: { callId: 'tool-1' },
512
+ response: { responseParts: [{ text: 'tool output' }] },
513
+ } as CompletedToolCall;
514
+ const abortController3 = new AbortController();
515
+ for await (const _ of task.sendCompletedToolsToLlm(
516
+ [completedTool],
517
+ abortController3.signal,
518
+ )) {
519
+ // no-op
520
+ }
521
+
522
+ expect(task.promptCount).toBe(2);
523
+ expect(task.currentPromptId).toBe(expectedPromptId2);
524
+ });
525
+ });
526
+
527
+ describe('Race Condition Fix', () => {
528
+ const mockConfig = createMockConfig();
529
+ const mockEventBus: ExecutionEventBus = {
530
+ publish: vi.fn(),
531
+ on: vi.fn(),
532
+ off: vi.fn(),
533
+ once: vi.fn(),
534
+ removeAllListeners: vi.fn(),
535
+ finished: vi.fn(),
536
+ };
537
+
538
+ beforeEach(() => {
539
+ vi.clearAllMocks();
540
+ });
541
+
542
+ it('should NOT transition to input-required if a tool is still validating', async () => {
543
+ // @ts-expect-error - Calling private constructor
544
+ const task = new Task(
545
+ 'task-id',
546
+ 'context-id',
547
+ mockConfig as Config,
548
+ mockEventBus,
549
+ );
550
+
551
+ // Manually register two tool calls
552
+ task['_registerToolCall']('tool-1', 'awaiting_approval');
553
+ task['_registerToolCall']('tool-2', 'validating');
554
+
555
+ // Call checkInputRequiredState (private)
556
+ task['checkInputRequiredState']();
557
+
558
+ // Verify task state did NOT change to input-required
559
+ expect(task.taskState).not.toBe('input-required');
560
+ expect(mockEventBus.publish).not.toHaveBeenCalledWith(
561
+ expect.objectContaining({
562
+ status: expect.objectContaining({ state: 'input-required' }),
563
+ }),
564
+ );
565
+ });
566
+
567
+ it('should transition to input-required if all active tools are awaiting approval', async () => {
568
+ // @ts-expect-error - Calling private constructor
569
+ const task = new Task(
570
+ 'task-id',
571
+ 'context-id',
572
+ mockConfig as Config,
573
+ mockEventBus,
574
+ );
575
+
576
+ // Transition from submitted to working first to simulate normal flow
577
+ task.taskState = 'working';
578
+
579
+ // Manually register tool calls
580
+ task['_registerToolCall']('tool-1', 'awaiting_approval');
581
+
582
+ // Call checkInputRequiredState
583
+ task['checkInputRequiredState']();
584
+
585
+ // Verify task state changed to input-required
586
+ expect(task.taskState).toBe('input-required');
587
+ expect(mockEventBus.publish).toHaveBeenCalledWith(
588
+ expect.objectContaining({
589
+ status: expect.objectContaining({ state: 'input-required' }),
590
+ }),
591
+ );
592
+ });
593
+
594
+ it('handleEventDrivenToolCallsUpdate should ignore events for other schedulers', async () => {
595
+ // @ts-expect-error - Calling private constructor
596
+ const task = new Task(
597
+ 'task-id',
598
+ 'context-id',
599
+ mockConfig as Config,
600
+ mockEventBus,
601
+ );
602
+
603
+ const handleEventDrivenToolCallSpy = vi.spyOn(
604
+ task as unknown as {
605
+ handleEventDrivenToolCall: Task['handleEventDrivenToolCall'];
606
+ },
607
+ 'handleEventDrivenToolCall',
608
+ );
609
+
610
+ const otherEvent: ToolCallsUpdateMessage = {
611
+ type: MessageBusType.TOOL_CALLS_UPDATE,
612
+ toolCalls: [
613
+ { request: { callId: '1' }, status: 'executing' } as ToolCall,
614
+ ],
615
+ schedulerId: 'other-task-id',
616
+ };
617
+
618
+ task['handleEventDrivenToolCallsUpdate'](otherEvent);
619
+
620
+ expect(handleEventDrivenToolCallSpy).not.toHaveBeenCalled();
621
+
622
+ const ownEvent: ToolCallsUpdateMessage = {
623
+ type: MessageBusType.TOOL_CALLS_UPDATE,
624
+ toolCalls: [
625
+ { request: { callId: '1' }, status: 'executing' } as ToolCall,
626
+ ],
627
+ schedulerId: 'task-id',
628
+ };
629
+
630
+ task['handleEventDrivenToolCallsUpdate'](ownEvent);
631
+
632
+ expect(handleEventDrivenToolCallSpy).toHaveBeenCalled();
633
+ });
634
+
635
+ describe('Pending Tools state', () => {
636
+ it('should correctly report pending tools presence and count', () => {
637
+ const mockConfig = createMockConfig();
638
+ const mockEventBus: ExecutionEventBus = {
639
+ publish: vi.fn(),
640
+ on: vi.fn(),
641
+ off: vi.fn(),
642
+ once: vi.fn(),
643
+ removeAllListeners: vi.fn(),
644
+ finished: vi.fn(),
645
+ };
646
+
647
+ // @ts-expect-error - Calling private constructor
648
+ const task = new Task(
649
+ 'task-id',
650
+ 'context-id',
651
+ mockConfig as Config,
652
+ mockEventBus,
653
+ );
654
+
655
+ expect(task.hasPendingTools).toBe(false);
656
+ expect(task.pendingToolsCount).toBe(0);
657
+
658
+ task['_registerToolCall']('tool-1', 'scheduled');
659
+ expect(task.hasPendingTools).toBe(true);
660
+ expect(task.pendingToolsCount).toBe(1);
661
+ });
662
+ });
663
+ });
664
+
665
+ describe('Serialization and Mapping', () => {
666
+ it('should map internal "validating" status to "scheduled" for the client and include outcome', async () => {
667
+ const mockConfig = createMockConfig();
668
+ const mockEventBus: ExecutionEventBus = {
669
+ publish: vi.fn(),
670
+ on: vi.fn(),
671
+ off: vi.fn(),
672
+ once: vi.fn(),
673
+ removeAllListeners: vi.fn(),
674
+ finished: vi.fn(),
675
+ };
676
+
677
+ // @ts-expect-error - Calling private constructor
678
+ const task = new Task(
679
+ 'task-id',
680
+ 'context-id',
681
+ mockConfig as Config,
682
+ mockEventBus,
683
+ );
684
+
685
+ const mockToolCall = {
686
+ request: { callId: 'tool-1' },
687
+ status: 'validating',
688
+ outcome: 'accepted',
689
+ tool: { name: 'test-tool' },
690
+ };
691
+
692
+ const message = task['toolStatusMessage'](
693
+ mockToolCall as unknown as ToolCall,
694
+ 'task-id',
695
+ 'context-id',
696
+ );
697
+ const serialized = (
698
+ message.parts![0] as {
699
+ data: { status: string; outcome: string };
700
+ }
701
+ ).data;
702
+
703
+ expect(serialized.status).toBe('scheduled');
704
+ expect(serialized.outcome).toBe('accepted');
705
+ });
706
+
707
+ it('should correctly detect changes when status or outcome changes', async () => {
708
+ const mockConfig = createMockConfig();
709
+ const mockEventBus: ExecutionEventBus = {
710
+ publish: vi.fn(),
711
+ on: vi.fn(),
712
+ off: vi.fn(),
713
+ once: vi.fn(),
714
+ removeAllListeners: vi.fn(),
715
+ finished: vi.fn(),
716
+ };
717
+
718
+ // @ts-expect-error - Calling private constructor
719
+ const task = new Task(
720
+ 'task-id',
721
+ 'context-id',
722
+ mockConfig as Config,
723
+ mockEventBus,
724
+ );
725
+
726
+ const toolCall1 = {
727
+ request: { callId: 'tool-1' },
728
+ status: 'awaiting_approval',
729
+ };
730
+
731
+ // First update - should trigger change
732
+ const changed1 = task['handleEventDrivenToolCall'](
733
+ toolCall1 as unknown as ToolCall,
734
+ );
735
+ expect(changed1).toBe(true);
736
+
737
+ // Second update with same status - should NOT trigger change
738
+ const changed2 = task['handleEventDrivenToolCall'](
739
+ toolCall1 as unknown as ToolCall,
740
+ );
741
+ expect(changed2).toBe(false);
742
+
743
+ // Update with new outcome - SHOULD trigger change
744
+ const toolCall2 = {
745
+ request: { callId: 'tool-1' },
746
+ status: 'awaiting_approval',
747
+ outcome: 'accepted',
748
+ };
749
+ const changed3 = task['handleEventDrivenToolCall'](
750
+ toolCall2 as unknown as ToolCall,
751
+ );
752
+ expect(changed3).toBe(true);
753
+ });
754
+ });
755
+
756
+ describe('getProposedContent (CRLF Line Ending Normalization)', () => {
757
+ it('should successfully replace LF-based strings in CRLF-based files', async () => {
758
+ const fs = await import('node:fs');
759
+ const path = await import('node:path');
760
+ const os = await import('node:os');
761
+
762
+ const mockConfig = createMockConfig({
763
+ getTargetDir: () => os.tmpdir(),
764
+ validatePathAccess: () => null,
765
+ });
766
+ const mockEventBus: ExecutionEventBus = {
767
+ publish: vi.fn(),
768
+ on: vi.fn(),
769
+ off: vi.fn(),
770
+ once: vi.fn(),
771
+ removeAllListeners: vi.fn(),
772
+ finished: vi.fn(),
773
+ };
774
+
775
+ // @ts-expect-error - Calling private constructor
776
+ const task = new Task(
777
+ 'task-id',
778
+ 'context-id',
779
+ mockConfig as Config,
780
+ mockEventBus,
781
+ );
782
+
783
+ const tempFile = path.resolve(os.tmpdir(), 'crlf_test_file.txt');
784
+ const crlfContent = 'line1\r\nline2\r\nline3\r\n';
785
+ fs.writeFileSync(tempFile, crlfContent, 'utf8');
786
+
787
+ try {
788
+ const oldString = 'line2\n';
789
+ const newString = 'line2-optimized\n';
790
+
791
+ const result = await task['getProposedContent'](
792
+ tempFile,
793
+ oldString,
794
+ newString,
795
+ );
796
+
797
+ expect(result).toContain('line2-optimized');
798
+ expect(result).toContain('\r\n'); // It should preserve the original CRLF line endings
799
+ } finally {
800
+ if (fs.existsSync(tempFile)) {
801
+ fs.unlinkSync(tempFile);
802
+ }
803
+ }
804
+ });
805
+
806
+ it('should successfully replace CRLF-based strings in CRLF-based files by normalizing all to LF', async () => {
807
+ const fs = await import('node:fs');
808
+ const path = await import('node:path');
809
+ const os = await import('node:os');
810
+
811
+ const mockConfig = createMockConfig({
812
+ getTargetDir: () => os.tmpdir(),
813
+ validatePathAccess: () => null,
814
+ });
815
+ const mockEventBus: ExecutionEventBus = {
816
+ publish: vi.fn(),
817
+ on: vi.fn(),
818
+ off: vi.fn(),
819
+ once: vi.fn(),
820
+ removeAllListeners: vi.fn(),
821
+ finished: vi.fn(),
822
+ };
823
+
824
+ // @ts-expect-error - Calling private constructor
825
+ const task = new Task(
826
+ 'task-id',
827
+ 'context-id',
828
+ mockConfig as Config,
829
+ mockEventBus,
830
+ );
831
+
832
+ const tempFile = path.resolve(
833
+ os.tmpdir(),
834
+ 'crlf_test_file_crlf_inputs.txt',
835
+ );
836
+ const crlfContent = 'line1\r\nline2\r\nline3\r\n';
837
+ fs.writeFileSync(tempFile, crlfContent, 'utf8');
838
+
839
+ try {
840
+ const oldString = 'line2\r\n';
841
+ const newString = 'line2-optimized\r\n';
842
+
843
+ const result = await task['getProposedContent'](
844
+ tempFile,
845
+ oldString,
846
+ newString,
847
+ );
848
+
849
+ expect(result).toContain('line2-optimized');
850
+ expect(result).toContain('\r\n'); // It should preserve the original CRLF line endings
851
+ } finally {
852
+ if (fs.existsSync(tempFile)) {
853
+ fs.unlinkSync(tempFile);
854
+ }
855
+ }
856
+ });
857
+ });
858
+
859
+ describe('Stale Cancellation Error Cleanup', () => {
860
+ it('should clear any previous execution cancellation error on acceptUserMessage', async () => {
861
+ const mockConfig = createMockConfig();
862
+ mockConfig.getGeminiClient = vi.fn().mockReturnValue({
863
+ sendMessageStream: vi.fn().mockReturnValue((async function* () {})()),
864
+ });
865
+ mockConfig.getSessionId = () => 'test-session-id';
866
+
867
+ const mockEventBus: ExecutionEventBus = {
868
+ publish: vi.fn(),
869
+ on: vi.fn(),
870
+ off: vi.fn(),
871
+ once: vi.fn(),
872
+ removeAllListeners: vi.fn(),
873
+ finished: vi.fn(),
874
+ };
875
+
876
+ // @ts-expect-error - Calling private constructor
877
+ const task = new Task(
878
+ 'task-id',
879
+ 'context-id',
880
+ mockConfig as Config,
881
+ mockEventBus,
882
+ );
883
+
884
+ // Simulate cancellation from a previous aborted execution turn
885
+ task.cancelPendingTools('Execution aborted');
886
+
887
+ // Verify cancellationError is set and would throw on wait
888
+ expect(task['cancellationError']).toBeDefined();
889
+ await expect(task.waitForPendingTools()).rejects.toThrow(
890
+ 'Execution aborted',
891
+ );
892
+
893
+ // Call acceptUserMessage to start a new user turn
894
+ const userMessage = {
895
+ userMessage: {
896
+ parts: [{ kind: 'text', text: 'new prompt' }],
897
+ },
898
+ } as RequestContext;
899
+ const abortController = new AbortController();
900
+
901
+ // Drain the generator to let acceptUserMessage execute
902
+ for await (const _ of task.acceptUserMessage(
903
+ userMessage,
904
+ abortController.signal,
905
+ )) {
906
+ // no-op
907
+ }
908
+
909
+ // Verify cancellationError is cleared
910
+ expect(task['cancellationError']).toBeUndefined();
911
+
912
+ // Verify waiting for pending tools now succeeds/does not throw
913
+ await expect(task.waitForPendingTools()).resolves.toBeUndefined();
914
+ });
915
+ });
916
+ });
packages/a2a-server/src/agent/task.ts ADDED
@@ -0,0 +1,1305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ type AgentLoopContext,
9
+ Scheduler,
10
+ type GeminiClient,
11
+ GeminiEventType,
12
+ ToolConfirmationOutcome,
13
+ ApprovalMode,
14
+ CoreToolCallStatus,
15
+ getAllMCPServerStatuses,
16
+ MCPServerStatus,
17
+ isNodeError,
18
+ getErrorMessage,
19
+ parseAndFormatApiError,
20
+ safeLiteralReplace,
21
+ DEFAULT_GUI_EDITOR,
22
+ type AnyDeclarativeTool,
23
+ type ToolCall,
24
+ type ToolConfirmationPayload,
25
+ type CompletedToolCall,
26
+ type ToolCallRequestInfo,
27
+ type ServerGeminiErrorEvent,
28
+ type ServerGeminiStreamEvent,
29
+ type ToolCallConfirmationDetails,
30
+ type Config,
31
+ type UserTierId,
32
+ type ToolLiveOutput,
33
+ type AnsiLine,
34
+ type AnsiOutput,
35
+ type AnsiToken,
36
+ isSubagentProgress,
37
+ EDIT_TOOL_NAMES,
38
+ processRestorableToolCalls,
39
+ MessageBusType,
40
+ type ToolCallsUpdateMessage,
41
+ } from '@google/gemini-cli-core';
42
+ import {
43
+ type ExecutionEventBus,
44
+ type RequestContext,
45
+ } from '@a2a-js/sdk/server';
46
+ import type {
47
+ TaskStatusUpdateEvent,
48
+ TaskArtifactUpdateEvent,
49
+ TaskState,
50
+ Message,
51
+ Part,
52
+ Artifact,
53
+ } from '@a2a-js/sdk';
54
+ import { v4 as uuidv4 } from 'uuid';
55
+ import { EventEmitter } from 'node:events';
56
+ import { logger } from '../utils/logger.js';
57
+ import * as fs from 'node:fs/promises';
58
+ import * as path from 'node:path';
59
+ import {
60
+ CoderAgentEvent,
61
+ type CoderAgentMessage,
62
+ type StateChange,
63
+ type ToolCallUpdate,
64
+ type TextContent,
65
+ type TaskMetadata,
66
+ type Thought,
67
+ type ThoughtSummary,
68
+ type Citation,
69
+ } from '../types.js';
70
+ import type { PartUnion, Part as genAiPart } from '@google/genai';
71
+
72
+ type UnionKeys<T> = T extends T ? keyof T : never;
73
+
74
+ export class Task {
75
+ id: string;
76
+ contextId: string;
77
+ scheduler: Scheduler;
78
+ config: Config;
79
+ geminiClient: GeminiClient;
80
+ pendingToolConfirmationDetails: Map<string, ToolCallConfirmationDetails>;
81
+ pendingCorrelationIds: Map<string, string> = new Map();
82
+ taskState: TaskState;
83
+ eventBus?: ExecutionEventBus;
84
+ completedToolCalls: CompletedToolCall[];
85
+ processedToolCallIds: Set<string> = new Set();
86
+ skipFinalTrueAfterInlineEdit = false;
87
+ modelInfo?: string;
88
+ currentPromptId: string | undefined;
89
+ currentAgentMessageId = uuidv4();
90
+ promptCount = 0;
91
+ autoExecute: boolean;
92
+ usageMetadata?: {
93
+ promptTokenCount?: number;
94
+ candidatesTokenCount?: number;
95
+ totalTokenCount?: number;
96
+ cachedContentTokenCount?: number;
97
+ };
98
+ private get isYoloMatch(): boolean {
99
+ return (
100
+ this.autoExecute || this.config.getApprovalMode() === ApprovalMode.YOLO
101
+ );
102
+ }
103
+
104
+ // For tool waiting logic
105
+ private pendingToolCalls: Map<string, string> = new Map(); //toolCallId --> status
106
+ private pendingOutcomes: Map<string, ToolConfirmationOutcome | undefined> =
107
+ new Map(); // toolCallId --> outcome
108
+ private toolsAlreadyConfirmed: Set<string> = new Set();
109
+ private toolUpdateEmitter = new EventEmitter();
110
+ private cancellationError?: Error;
111
+
112
+ private constructor(
113
+ id: string,
114
+ contextId: string,
115
+ config: Config,
116
+ eventBus?: ExecutionEventBus,
117
+ autoExecute = false,
118
+ ) {
119
+ this.id = id;
120
+ this.contextId = contextId;
121
+ this.config = config;
122
+
123
+ this.scheduler = this.setupEventDrivenScheduler();
124
+
125
+ const loopContext: AgentLoopContext = this.config;
126
+ this.geminiClient = loopContext.geminiClient;
127
+ this.pendingToolConfirmationDetails = new Map();
128
+ this.taskState = 'submitted';
129
+ this.eventBus = eventBus;
130
+ this.completedToolCalls = [];
131
+ this.autoExecute = autoExecute;
132
+ this.config.setFallbackModelHandler(
133
+ // For a2a-server, we want to automatically switch to the fallback model
134
+ // for future requests without retrying the current one.
135
+ async (failedModel, fallbackModel) => {
136
+ this.config.activateFallbackMode(fallbackModel, failedModel);
137
+ return 'stop';
138
+ },
139
+ );
140
+ }
141
+
142
+ get hasPendingTools(): boolean {
143
+ return this.pendingToolCalls.size > 0;
144
+ }
145
+
146
+ get pendingToolsCount(): number {
147
+ return this.pendingToolCalls.size;
148
+ }
149
+
150
+ static async create(
151
+ id: string,
152
+ contextId: string,
153
+ config: Config,
154
+ eventBus?: ExecutionEventBus,
155
+ autoExecute?: boolean,
156
+ ): Promise<Task> {
157
+ return new Task(id, contextId, config, eventBus, autoExecute);
158
+ }
159
+
160
+ // Note: `getAllMCPServerStatuses` retrieves the status of all MCP servers for the entire
161
+ // process. This is not scoped to the individual task but reflects the global connection
162
+ // state managed within the @gemini-cli/core module.
163
+ async getMetadata(): Promise<TaskMetadata> {
164
+ const loopContext: AgentLoopContext = this.config;
165
+ const toolRegistry = loopContext.toolRegistry;
166
+ const mcpServers = this.config.getMcpClientManager()?.getMcpServers() || {};
167
+ const serverStatuses = getAllMCPServerStatuses();
168
+ const servers = Object.keys(mcpServers).map((serverName) => ({
169
+ name: serverName,
170
+ status: serverStatuses.get(serverName) || MCPServerStatus.DISCONNECTED,
171
+ tools: toolRegistry.getToolsByServer(serverName).map((tool) => ({
172
+ name: tool.name,
173
+ description: tool.description,
174
+ parameterSchema: tool.schema.parameters,
175
+ })),
176
+ }));
177
+
178
+ const availableTools = toolRegistry.getAllTools().map((tool) => ({
179
+ name: tool.name,
180
+ description: tool.description,
181
+ parameterSchema: tool.schema.parameters,
182
+ }));
183
+
184
+ const metadata: TaskMetadata = {
185
+ id: this.id,
186
+ contextId: this.contextId,
187
+ taskState: this.taskState,
188
+ model: this.modelInfo || this.config.getModel(),
189
+ mcpServers: servers,
190
+ availableTools,
191
+ };
192
+ return metadata;
193
+ }
194
+
195
+ private _registerToolCall(toolCallId: string, status: string): void {
196
+ this.pendingToolCalls.set(toolCallId, status);
197
+ this.toolUpdateEmitter.emit('update');
198
+ logger.info(
199
+ `[Task] Registered tool call: ${toolCallId}. Pending: ${this.pendingToolCalls.size}`,
200
+ );
201
+ }
202
+
203
+ private _resolveToolCall(toolCallId: string): void {
204
+ if (this.pendingToolCalls.has(toolCallId)) {
205
+ this.pendingToolCalls.delete(toolCallId);
206
+ this.toolUpdateEmitter.emit('update');
207
+ logger.info(
208
+ `[Task] Resolved tool call: ${toolCallId}. Pending: ${this.pendingToolCalls.size}`,
209
+ );
210
+ }
211
+ }
212
+
213
+ private isAwaitingApprovalOnly(): boolean {
214
+ if (this.pendingToolCalls.size === 0) {
215
+ return false;
216
+ }
217
+ for (const [callId, status] of this.pendingToolCalls.entries()) {
218
+ if (
219
+ status !== CoreToolCallStatus.AwaitingApproval ||
220
+ this.toolsAlreadyConfirmed.has(callId)
221
+ ) {
222
+ return false;
223
+ }
224
+ }
225
+ return true;
226
+ }
227
+
228
+ async waitForPendingTools(): Promise<void> {
229
+ while (this.pendingToolCalls.size > 0 && !this.isAwaitingApprovalOnly()) {
230
+ if (this.cancellationError) {
231
+ const error = this.cancellationError;
232
+ this.cancellationError = undefined;
233
+ throw error;
234
+ }
235
+ logger.info(
236
+ `[Task] Waiting for ${this.pendingToolCalls.size} pending tool(s)...`,
237
+ );
238
+ await new Promise((resolve) =>
239
+ this.toolUpdateEmitter.once('update', resolve),
240
+ );
241
+ }
242
+ if (this.cancellationError) {
243
+ const error = this.cancellationError;
244
+ this.cancellationError = undefined;
245
+ throw error;
246
+ }
247
+ }
248
+
249
+ cancelPendingTools(reason: string): void {
250
+ if (this.pendingToolCalls.size > 0) {
251
+ logger.info(
252
+ `[Task] Cancelling all ${this.pendingToolCalls.size} pending tool calls. Reason: ${reason}`,
253
+ );
254
+ }
255
+ this.cancellationError = new Error(reason);
256
+ this.pendingToolCalls.clear();
257
+ this.pendingCorrelationIds.clear();
258
+ this.toolsAlreadyConfirmed.clear();
259
+
260
+ this.scheduler.cancelAll();
261
+ this.toolUpdateEmitter.emit('update');
262
+ }
263
+
264
+ private _createTextMessage(
265
+ text: string,
266
+ role: 'agent' | 'user' = 'agent',
267
+ ): Message {
268
+ return {
269
+ kind: 'message',
270
+ role,
271
+ parts: [{ kind: 'text', text }],
272
+ messageId: role === 'agent' ? this.currentAgentMessageId : uuidv4(),
273
+ taskId: this.id,
274
+ contextId: this.contextId,
275
+ };
276
+ }
277
+
278
+ private _createStatusUpdateEvent(
279
+ stateToReport: TaskState,
280
+ coderAgentMessage: CoderAgentMessage,
281
+ message?: Message,
282
+ final = false,
283
+ timestamp?: string,
284
+ metadataError?: string,
285
+ traceId?: string,
286
+ ): TaskStatusUpdateEvent {
287
+ const metadata: {
288
+ coderAgent: CoderAgentMessage;
289
+ model: string;
290
+ userTier?: UserTierId;
291
+ error?: string;
292
+ traceId?: string;
293
+ usageMetadata?: Task['usageMetadata'];
294
+ } = {
295
+ coderAgent: coderAgentMessage,
296
+ model: this.modelInfo || this.config.getModel(),
297
+ userTier: this.config.getUserTier(),
298
+ };
299
+
300
+ if (metadataError) {
301
+ metadata.error = metadataError;
302
+ }
303
+
304
+ if (traceId) {
305
+ metadata.traceId = traceId;
306
+ }
307
+
308
+ if (final && this.usageMetadata) {
309
+ metadata.usageMetadata = this.usageMetadata;
310
+ }
311
+
312
+ return {
313
+ kind: 'status-update',
314
+ taskId: this.id,
315
+ contextId: this.contextId,
316
+ status: {
317
+ state: stateToReport,
318
+ message, // Shorthand property
319
+ timestamp: timestamp || new Date().toISOString(),
320
+ },
321
+ final,
322
+ metadata,
323
+ };
324
+ }
325
+
326
+ setTaskStateAndPublishUpdate(
327
+ newState: TaskState,
328
+ coderAgentMessage: CoderAgentMessage,
329
+ messageText?: string,
330
+ messageParts?: Part[], // For more complex messages
331
+ final = false,
332
+ metadataError?: string,
333
+ traceId?: string,
334
+ ): void {
335
+ this.taskState = newState;
336
+ let message: Message | undefined;
337
+
338
+ if (messageText) {
339
+ message = this._createTextMessage(messageText);
340
+ } else if (messageParts) {
341
+ message = {
342
+ kind: 'message',
343
+ role: 'agent',
344
+ parts: messageParts,
345
+ messageId: uuidv4(),
346
+ taskId: this.id,
347
+ contextId: this.contextId,
348
+ };
349
+ }
350
+
351
+ const event = this._createStatusUpdateEvent(
352
+ this.taskState,
353
+ coderAgentMessage,
354
+ message,
355
+ final,
356
+ undefined,
357
+ metadataError,
358
+ traceId,
359
+ );
360
+ this.eventBus?.publish(event);
361
+ }
362
+
363
+ private _schedulerOutputUpdate(
364
+ toolCallId: string,
365
+ outputChunk: ToolLiveOutput,
366
+ ): void {
367
+ let outputAsText: string;
368
+ if (typeof outputChunk === 'string') {
369
+ outputAsText = outputChunk;
370
+ } else if (isSubagentProgress(outputChunk)) {
371
+ outputAsText = JSON.stringify(outputChunk);
372
+ } else if (Array.isArray(outputChunk)) {
373
+ const ansiOutput: AnsiOutput = outputChunk;
374
+ outputAsText = ansiOutput
375
+ .map((line: AnsiLine) =>
376
+ line.map((token: AnsiToken) => token.text).join(''),
377
+ )
378
+ .join('\n');
379
+ } else {
380
+ outputAsText = String(outputChunk);
381
+ }
382
+
383
+ logger.info(
384
+ '[Task] Scheduler output update for tool call ' +
385
+ toolCallId +
386
+ ': ' +
387
+ outputAsText,
388
+ );
389
+ const artifact: Artifact = {
390
+ artifactId: `tool-${toolCallId}-output`,
391
+ parts: [
392
+ {
393
+ kind: 'text',
394
+ text: outputAsText,
395
+ } as Part,
396
+ ],
397
+ };
398
+ const artifactEvent: TaskArtifactUpdateEvent = {
399
+ kind: 'artifact-update',
400
+ taskId: this.id,
401
+ contextId: this.contextId,
402
+ artifact,
403
+ append: true,
404
+ lastChunk: false,
405
+ };
406
+ this.eventBus?.publish(artifactEvent);
407
+ }
408
+
409
+ private messageBusListener?: (message: ToolCallsUpdateMessage) => void;
410
+
411
+ private setupEventDrivenScheduler(): Scheduler {
412
+ const loopContext: AgentLoopContext = this.config;
413
+ const messageBus = loopContext.messageBus;
414
+ const scheduler = new Scheduler({
415
+ schedulerId: this.id,
416
+ context: this.config,
417
+ messageBus,
418
+ getPreferredEditor: () => DEFAULT_GUI_EDITOR,
419
+ });
420
+
421
+ this.messageBusListener = this.handleEventDrivenToolCallsUpdate.bind(this);
422
+ messageBus.subscribe<ToolCallsUpdateMessage>(
423
+ MessageBusType.TOOL_CALLS_UPDATE,
424
+ this.messageBusListener,
425
+ );
426
+
427
+ return scheduler;
428
+ }
429
+
430
+ dispose(): void {
431
+ if (this.messageBusListener) {
432
+ const loopContext: AgentLoopContext = this.config;
433
+ loopContext.messageBus.unsubscribe(
434
+ MessageBusType.TOOL_CALLS_UPDATE,
435
+ this.messageBusListener,
436
+ );
437
+ this.messageBusListener = undefined;
438
+ }
439
+
440
+ this.scheduler.dispose();
441
+ }
442
+
443
+ private handleEventDrivenToolCallsUpdate(
444
+ event: ToolCallsUpdateMessage,
445
+ ): void {
446
+ if (
447
+ event.type !== MessageBusType.TOOL_CALLS_UPDATE ||
448
+ event.schedulerId !== this.id
449
+ ) {
450
+ return;
451
+ }
452
+
453
+ const toolCalls = event.toolCalls;
454
+
455
+ toolCalls.forEach((tc) => {
456
+ this.handleEventDrivenToolCall(tc);
457
+ });
458
+
459
+ this.checkInputRequiredState();
460
+ }
461
+
462
+ private handleEventDrivenToolCall(tc: ToolCall): boolean {
463
+ const callId = tc.request.callId;
464
+
465
+ // Do not process events for tools that have already been finalized.
466
+ // This prevents duplicate completions if the state manager emits a snapshot containing
467
+ // already resolved tools whose IDs were removed from pendingToolCalls.
468
+ if (
469
+ this.processedToolCallIds.has(callId) ||
470
+ this.completedToolCalls.some((c) => c.request.callId === callId)
471
+ ) {
472
+ return false;
473
+ }
474
+
475
+ const previousStatus = this.pendingToolCalls.get(callId);
476
+ const previousOutcome = this.pendingOutcomes.get(callId);
477
+ const hasChanged =
478
+ previousStatus !== tc.status || previousOutcome !== tc.outcome;
479
+
480
+ // Update outcome tracking
481
+ this.pendingOutcomes.set(callId, tc.outcome);
482
+
483
+ // 1. Handle Output
484
+ if (tc.status === 'executing' && tc.liveOutput) {
485
+ this._schedulerOutputUpdate(callId, tc.liveOutput);
486
+ }
487
+
488
+ // 2. Handle terminal states
489
+ if (
490
+ tc.status === 'success' ||
491
+ tc.status === 'error' ||
492
+ tc.status === 'cancelled'
493
+ ) {
494
+ this.toolsAlreadyConfirmed.delete(callId);
495
+ this.pendingOutcomes.delete(callId);
496
+ if (hasChanged) {
497
+ logger.info(
498
+ `[Task] Tool call ${callId} completed with status: ${tc.status}`,
499
+ );
500
+ this.completedToolCalls.push(tc);
501
+ this._resolveToolCall(callId);
502
+ }
503
+ } else {
504
+ // Keep track of pending tools
505
+ this._registerToolCall(callId, tc.status);
506
+ }
507
+
508
+ // 3. Handle Confirmation Stash
509
+ if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
510
+ const details = tc.confirmationDetails;
511
+
512
+ if (tc.correlationId) {
513
+ this.pendingCorrelationIds.set(callId, tc.correlationId);
514
+ }
515
+
516
+ this.pendingToolConfirmationDetails.set(callId, {
517
+ ...details,
518
+ onConfirm: async () => {},
519
+ } as ToolCallConfirmationDetails);
520
+ }
521
+
522
+ // 4. Publish Status Updates to A2A event bus
523
+ if (hasChanged) {
524
+ const coderAgentMessage: CoderAgentMessage =
525
+ tc.status === 'awaiting_approval'
526
+ ? { kind: CoderAgentEvent.ToolCallConfirmationEvent }
527
+ : { kind: CoderAgentEvent.ToolCallUpdateEvent };
528
+
529
+ const message = this.toolStatusMessage(tc, this.id, this.contextId);
530
+ const statusUpdate = this._createStatusUpdateEvent(
531
+ this.taskState,
532
+ coderAgentMessage,
533
+ message,
534
+ false,
535
+ );
536
+ this.eventBus?.publish(statusUpdate);
537
+ }
538
+
539
+ return hasChanged;
540
+ }
541
+
542
+ private checkInputRequiredState(): void {
543
+ if (this.isYoloMatch) {
544
+ return;
545
+ }
546
+
547
+ // 6. Handle Input Required State
548
+ let isAwaitingApproval = false;
549
+ let isExecuting = false;
550
+
551
+ for (const [callId, status] of this.pendingToolCalls.entries()) {
552
+ if (
553
+ status === CoreToolCallStatus.Executing ||
554
+ status === CoreToolCallStatus.Scheduled ||
555
+ status === CoreToolCallStatus.Validating ||
556
+ this.toolsAlreadyConfirmed.has(callId)
557
+ ) {
558
+ isExecuting = true;
559
+ } else if (status === CoreToolCallStatus.AwaitingApproval) {
560
+ isAwaitingApproval = true;
561
+ }
562
+ }
563
+
564
+ if (
565
+ isAwaitingApproval &&
566
+ !isExecuting &&
567
+ !this.skipFinalTrueAfterInlineEdit
568
+ ) {
569
+ this.skipFinalTrueAfterInlineEdit = false;
570
+ const wasAlreadyInputRequired = this.taskState === 'input-required';
571
+
572
+ this.setTaskStateAndPublishUpdate(
573
+ 'input-required',
574
+ { kind: CoderAgentEvent.StateChangeEvent },
575
+ undefined,
576
+ undefined,
577
+ /*final*/ true,
578
+ );
579
+
580
+ // Unblock waitForPendingTools to correctly end the executor loop and release the HTTP response stream.
581
+ // The IDE client will open a new stream with the confirmation reply.
582
+ if (!wasAlreadyInputRequired) {
583
+ this.toolUpdateEmitter.emit('update');
584
+ }
585
+ }
586
+ }
587
+
588
+ private _pickFields<
589
+ T extends ToolCall | AnyDeclarativeTool,
590
+ K extends UnionKeys<T>,
591
+ >(from: T, ...fields: K[]): Partial<T> {
592
+ const ret: Partial<T> = {};
593
+ for (const field of fields) {
594
+ if (field in from && from[field] !== undefined) {
595
+ ret[field] = from[field];
596
+ }
597
+ }
598
+ return ret;
599
+ }
600
+
601
+ private toolStatusMessage(
602
+ tc: ToolCall,
603
+ taskId: string,
604
+ contextId: string,
605
+ ): Message {
606
+ const messageParts: Part[] = [];
607
+
608
+ // Create a serializable version of the ToolCall (pick necessary
609
+ // properties/avoid methods causing circular reference errors).
610
+ // Type allows tool to be Partial<AnyDeclarativeTool> for serialization.
611
+ const serializableToolCall: Partial<Omit<ToolCall, 'tool'>> & {
612
+ tool?: Partial<AnyDeclarativeTool>;
613
+ } = this._pickFields(
614
+ tc,
615
+ 'request',
616
+ 'status',
617
+ 'confirmationDetails',
618
+ 'liveOutput',
619
+ 'response',
620
+ 'outcome',
621
+ );
622
+
623
+ // Map internal 'validating' status to 'scheduled' for the client
624
+ if (serializableToolCall.status === CoreToolCallStatus.Validating) {
625
+ serializableToolCall.status = CoreToolCallStatus.Scheduled;
626
+ }
627
+
628
+ if (tc.tool) {
629
+ const toolFields = this._pickFields(
630
+ tc.tool,
631
+ 'name',
632
+ 'displayName',
633
+ 'description',
634
+ 'kind',
635
+ 'isOutputMarkdown',
636
+ 'canUpdateOutput',
637
+ 'schema',
638
+ 'parameterSchema',
639
+ );
640
+ serializableToolCall.tool = toolFields;
641
+ }
642
+
643
+ messageParts.push({
644
+ kind: 'data',
645
+ data: serializableToolCall,
646
+ } as Part);
647
+
648
+ return {
649
+ kind: 'message',
650
+ role: 'agent',
651
+ parts: messageParts,
652
+ messageId: uuidv4(),
653
+ taskId,
654
+ contextId,
655
+ };
656
+ }
657
+
658
+ private async getProposedContent(
659
+ file_path: string,
660
+ old_string: string,
661
+ new_string: string,
662
+ ): Promise<string> {
663
+ // Validate path to prevent path traversal vulnerabilities
664
+ const resolvedPath = path.resolve(this.config.getTargetDir(), file_path);
665
+ const pathError = this.config.validatePathAccess(resolvedPath, 'read');
666
+ if (pathError) {
667
+ throw new Error(`Path validation failed: ${pathError}`);
668
+ }
669
+
670
+ try {
671
+ const rawContent = await fs.readFile(resolvedPath, 'utf8');
672
+ const hasCrlf = rawContent.includes('\r\n');
673
+ const currentContent = rawContent.replace(/\r\n/g, '\n');
674
+ const normalizedOldString = old_string.replace(/\r\n/g, '\n');
675
+ const normalizedNewString = new_string.replace(/\r\n/g, '\n');
676
+ const proposedContent = this._applyReplacement(
677
+ currentContent,
678
+ normalizedOldString,
679
+ normalizedNewString,
680
+ normalizedOldString === '' && currentContent === '',
681
+ );
682
+ return hasCrlf ? proposedContent.replace(/\n/g, '\r\n') : proposedContent;
683
+ } catch (err) {
684
+ if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
685
+ return '';
686
+ }
687
+ }
688
+
689
+ private _applyReplacement(
690
+ currentContent: string | null,
691
+ oldString: string,
692
+ newString: string,
693
+ isNewFile: boolean,
694
+ ): string {
695
+ if (isNewFile) {
696
+ return newString;
697
+ }
698
+ if (currentContent === null) {
699
+ // Should not happen if not a new file, but defensively return empty or newString if oldString is also empty
700
+ return oldString === '' ? newString : '';
701
+ }
702
+ // If oldString is empty and it's not a new file, do not modify the content.
703
+ if (oldString === '' && !isNewFile) {
704
+ return currentContent;
705
+ }
706
+
707
+ // Use intelligent replacement that handles $ sequences safely
708
+ return safeLiteralReplace(currentContent, oldString, newString);
709
+ }
710
+
711
+ async scheduleToolCalls(
712
+ requests: ToolCallRequestInfo[],
713
+ abortSignal: AbortSignal,
714
+ ): Promise<void> {
715
+ if (requests.length === 0) {
716
+ return;
717
+ }
718
+
719
+ // Set checkpoint file before any file modification tool executes
720
+ const restorableToolCalls = requests.filter((request) =>
721
+ EDIT_TOOL_NAMES.has(request.name),
722
+ );
723
+
724
+ if (
725
+ restorableToolCalls.length > 0 &&
726
+ this.config.getCheckpointingEnabled()
727
+ ) {
728
+ const gitService = await this.config.getGitService();
729
+ if (gitService) {
730
+ const { checkpointsToWrite, toolCallToCheckpointMap, errors } =
731
+ await processRestorableToolCalls(
732
+ restorableToolCalls,
733
+ gitService,
734
+ this.geminiClient,
735
+ );
736
+
737
+ if (errors.length > 0) {
738
+ errors.forEach((error) => logger.error(error));
739
+ }
740
+
741
+ if (checkpointsToWrite.size > 0) {
742
+ const checkpointDir =
743
+ this.config.storage.getProjectTempCheckpointsDir();
744
+ await fs.mkdir(checkpointDir, { recursive: true });
745
+ for (const [fileName, content] of checkpointsToWrite) {
746
+ const filePath = path.join(checkpointDir, fileName);
747
+ await fs.writeFile(filePath, content);
748
+ }
749
+ }
750
+
751
+ for (const request of requests) {
752
+ const checkpoint = toolCallToCheckpointMap.get(request.callId);
753
+ if (checkpoint) {
754
+ request.checkpoint = checkpoint;
755
+ }
756
+ }
757
+ }
758
+ }
759
+
760
+ const updatedRequests = await Promise.all(
761
+ requests.map(async (request) => {
762
+ if (
763
+ request.name === 'replace' &&
764
+ request.args &&
765
+ !request.args['newContent'] &&
766
+ request.args['file_path'] &&
767
+ request.args['old_string'] &&
768
+ request.args['new_string']
769
+ ) {
770
+ const filePath = request.args['file_path'];
771
+ const oldString = request.args['old_string'];
772
+ const newString = request.args['new_string'];
773
+ if (
774
+ typeof filePath === 'string' &&
775
+ typeof oldString === 'string' &&
776
+ typeof newString === 'string'
777
+ ) {
778
+ // Resolve and validate path to prevent path traversal (user-controlled file_path).
779
+ const resolvedPath = path.resolve(
780
+ this.config.getTargetDir(),
781
+ filePath,
782
+ );
783
+ const pathError = this.config.validatePathAccess(
784
+ resolvedPath,
785
+ 'read',
786
+ );
787
+ if (!pathError) {
788
+ const newContent = await this.getProposedContent(
789
+ resolvedPath,
790
+ oldString,
791
+ newString,
792
+ );
793
+ return { ...request, args: { ...request.args, newContent } };
794
+ }
795
+ }
796
+ }
797
+ return request;
798
+ }),
799
+ );
800
+
801
+ logger.info(
802
+ `[Task] Scheduling batch of ${updatedRequests.length} tool calls.`,
803
+ );
804
+ const stateChange: StateChange = {
805
+ kind: CoderAgentEvent.StateChangeEvent,
806
+ };
807
+ this.setTaskStateAndPublishUpdate('working', stateChange);
808
+
809
+ // Pre-register tools to ensure waitForPendingTools sees them as pending
810
+ // before the async scheduler enqueues them and fires the event bus update.
811
+ for (const req of updatedRequests) {
812
+ if (!this.pendingToolCalls.has(req.callId)) {
813
+ this._registerToolCall(req.callId, 'scheduled');
814
+ }
815
+ }
816
+
817
+ // Fire and forget so we don't block the executor loop before waitForPendingTools can be called
818
+ void this.scheduler.schedule(updatedRequests, abortSignal);
819
+ }
820
+
821
+ async acceptAgentMessage(event: ServerGeminiStreamEvent): Promise<void> {
822
+ const stateChange: StateChange = {
823
+ kind: CoderAgentEvent.StateChangeEvent,
824
+ };
825
+ const traceId =
826
+ 'traceId' in event && event.traceId ? event.traceId : undefined;
827
+
828
+ switch (event.type) {
829
+ case GeminiEventType.Content:
830
+ logger.info('[Task] Sending agent message content...');
831
+ this._sendTextContent(event.value, traceId);
832
+ break;
833
+ case GeminiEventType.ToolCallRequest:
834
+ // This is now handled by the agent loop, which collects all requests
835
+ // and calls scheduleToolCalls once.
836
+ logger.warn(
837
+ '[Task] A single tool call request was passed to acceptAgentMessage. This should be handled in a batch by the agent. Ignoring.',
838
+ );
839
+ break;
840
+ case GeminiEventType.ToolCallResponse:
841
+ // This event type from ServerGeminiStreamEvent might be for when LLM *generates* a tool response part.
842
+ // The actual execution result comes via user message.
843
+ logger.info(
844
+ '[Task] Received tool call response from LLM (part of generation):',
845
+ event.value,
846
+ );
847
+ break;
848
+ case GeminiEventType.ToolCallConfirmation:
849
+ // This is when LLM requests confirmation, not when user provides it.
850
+ logger.info(
851
+ '[Task] Received tool call confirmation request from LLM:',
852
+ event.value.request.callId,
853
+ );
854
+ this.pendingToolConfirmationDetails.set(
855
+ event.value.request.callId,
856
+ event.value.details,
857
+ );
858
+ // This will be handled by the scheduler and _schedulerToolCallsUpdate will set InputRequired if needed.
859
+ // No direct state change here, scheduler drives it.
860
+ break;
861
+ case GeminiEventType.UserCancelled:
862
+ logger.info('[Task] Received user cancelled event from LLM stream.');
863
+ this.cancelPendingTools('User cancelled via LLM stream event');
864
+ this.setTaskStateAndPublishUpdate(
865
+ 'input-required',
866
+ stateChange,
867
+ 'Task cancelled by user',
868
+ undefined,
869
+ true,
870
+ undefined,
871
+ traceId,
872
+ );
873
+ break;
874
+ case GeminiEventType.Thought:
875
+ logger.info('[Task] Sending agent thought...');
876
+ this._sendThought(event.value, traceId);
877
+ break;
878
+ case GeminiEventType.Citation:
879
+ logger.info('[Task] Received citation from LLM stream.');
880
+ this._sendCitation(event.value);
881
+ break;
882
+ case GeminiEventType.ChatCompressed:
883
+ break;
884
+ case GeminiEventType.Finished:
885
+ logger.info(`[Task ${this.id}] Agent finished its turn.`);
886
+ // Capture the usage metadata when the stream finishes
887
+ if (
888
+ event.value &&
889
+ typeof event.value === 'object' &&
890
+ 'usageMetadata' in event.value
891
+ ) {
892
+ this.usageMetadata = event.value
893
+ .usageMetadata as typeof this.usageMetadata;
894
+ }
895
+ break;
896
+ case GeminiEventType.ModelInfo:
897
+ this.usageMetadata = undefined;
898
+ this.modelInfo = event.value;
899
+ break;
900
+ case GeminiEventType.Retry:
901
+ case GeminiEventType.InvalidStream:
902
+ // An invalid stream should trigger a retry, which requires no action from the user.
903
+ break;
904
+ case GeminiEventType.Error:
905
+ default: {
906
+ // Use type guard instead of unsafe type assertion
907
+ let errorEvent: ServerGeminiErrorEvent | undefined;
908
+ if (
909
+ event.type === GeminiEventType.Error &&
910
+ event.value &&
911
+ typeof event.value === 'object' &&
912
+ 'error' in event.value
913
+ ) {
914
+ errorEvent = event;
915
+ }
916
+ const errorMessage = errorEvent?.value?.error
917
+ ? getErrorMessage(errorEvent.value.error)
918
+ : 'Unknown error from LLM stream';
919
+ logger.error(
920
+ '[Task] Received error event from LLM stream:',
921
+ errorMessage,
922
+ );
923
+
924
+ let errMessage = `Unknown error from LLM stream: ${JSON.stringify(event)}`;
925
+ if (errorEvent?.value?.error) {
926
+ errMessage = parseAndFormatApiError(errorEvent.value.error);
927
+ }
928
+ this.cancelPendingTools(`LLM stream error: ${errorMessage}`);
929
+ this.setTaskStateAndPublishUpdate(
930
+ this.taskState,
931
+ stateChange,
932
+ `Agent Error, unknown agent message: ${errorMessage}`,
933
+ undefined,
934
+ false,
935
+ errMessage,
936
+ traceId,
937
+ );
938
+ break;
939
+ }
940
+ }
941
+ }
942
+
943
+ private async _handleToolConfirmationPart(part: Part): Promise<boolean> {
944
+ if (
945
+ part.kind !== 'data' ||
946
+ !part.data ||
947
+ // eslint-disable-next-line no-restricted-syntax
948
+ typeof part.data['callId'] !== 'string' ||
949
+ // eslint-disable-next-line no-restricted-syntax
950
+ typeof part.data['outcome'] !== 'string'
951
+ ) {
952
+ return false;
953
+ }
954
+ if (!part.data['outcome']) {
955
+ return false;
956
+ }
957
+
958
+ const callId = part.data['callId'];
959
+ const outcomeString = part.data['outcome'];
960
+
961
+ this.toolsAlreadyConfirmed.add(callId);
962
+ this.toolUpdateEmitter.emit('update');
963
+
964
+ let confirmationOutcome: ToolConfirmationOutcome | undefined;
965
+
966
+ if (outcomeString === 'proceed_once') {
967
+ confirmationOutcome = ToolConfirmationOutcome.ProceedOnce;
968
+ } else if (outcomeString === 'cancel') {
969
+ confirmationOutcome = ToolConfirmationOutcome.Cancel;
970
+ } else if (outcomeString === 'proceed_always') {
971
+ confirmationOutcome = ToolConfirmationOutcome.ProceedAlways;
972
+ } else if (outcomeString === 'proceed_always_server') {
973
+ confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysServer;
974
+ } else if (outcomeString === 'proceed_always_tool') {
975
+ confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysTool;
976
+ } else if (outcomeString === 'proceed_always_and_save') {
977
+ confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysAndSave;
978
+ } else if (outcomeString === 'modify_with_editor') {
979
+ confirmationOutcome = ToolConfirmationOutcome.ModifyWithEditor;
980
+ } else {
981
+ logger.warn(
982
+ `[Task] Unknown tool confirmation outcome: "${outcomeString}" for callId: ${callId}`,
983
+ );
984
+ return false;
985
+ }
986
+
987
+ const confirmationDetails = this.pendingToolConfirmationDetails.get(callId);
988
+ const correlationId = this.pendingCorrelationIds.get(callId);
989
+
990
+ if (!confirmationDetails && !correlationId) {
991
+ logger.warn(
992
+ `[Task] Received tool confirmation for unknown or already processed callId: ${callId}`,
993
+ );
994
+ return false;
995
+ }
996
+
997
+ logger.info(
998
+ `[Task] Handling tool confirmation for callId: ${callId} with outcome: ${outcomeString}`,
999
+ );
1000
+ try {
1001
+ // Temporarily unset GCP environment variables so they do not leak into
1002
+ // tool calls.
1003
+ const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'];
1004
+ const gcpCreds = process.env['GOOGLE_APPLICATION_CREDENTIALS'];
1005
+ try {
1006
+ delete process.env['GOOGLE_CLOUD_PROJECT'];
1007
+ delete process.env['GOOGLE_APPLICATION_CREDENTIALS'];
1008
+
1009
+ // This will trigger the scheduler to continue or cancel the specific tool.
1010
+ // The scheduler's onToolCallsUpdate will then reflect the new state (e.g., executing or cancelled).
1011
+
1012
+ // If `edit` tool call, pass updated payload if present
1013
+ const newContent = part.data['newContent'];
1014
+ const payload =
1015
+ confirmationDetails?.type === 'edit' && typeof newContent === 'string'
1016
+ ? ({ newContent } as ToolConfirmationPayload)
1017
+ : undefined;
1018
+ this.skipFinalTrueAfterInlineEdit = !!payload;
1019
+
1020
+ try {
1021
+ if (correlationId) {
1022
+ const loopContext: AgentLoopContext = this.config;
1023
+ await loopContext.messageBus.publish({
1024
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
1025
+ correlationId,
1026
+ confirmed:
1027
+ confirmationOutcome !== ToolConfirmationOutcome.Cancel &&
1028
+ confirmationOutcome !==
1029
+ ToolConfirmationOutcome.ModifyWithEditor,
1030
+ outcome: confirmationOutcome,
1031
+ payload,
1032
+ });
1033
+ } else if (confirmationDetails?.onConfirm) {
1034
+ // Fallback for legacy callback-based confirmation
1035
+ await confirmationDetails.onConfirm(confirmationOutcome, payload);
1036
+ }
1037
+ } finally {
1038
+ // Once confirmation payload is sent or callback finishes,
1039
+ // reset skipFinalTrueAfterInlineEdit so that external callers receive
1040
+ // their call has been completed.
1041
+ this.skipFinalTrueAfterInlineEdit = false;
1042
+ }
1043
+ } finally {
1044
+ if (gcpProject) {
1045
+ process.env['GOOGLE_CLOUD_PROJECT'] = gcpProject;
1046
+ }
1047
+ if (gcpCreds) {
1048
+ process.env['GOOGLE_APPLICATION_CREDENTIALS'] = gcpCreds;
1049
+ }
1050
+ }
1051
+
1052
+ // Do not delete if modifying, a subsequent tool confirmation for the same
1053
+ // callId will be passed with ProceedOnce/Cancel/etc
1054
+ // Note !== ToolConfirmationOutcome.ModifyWithEditor does not work!
1055
+ if (confirmationOutcome !== 'modify_with_editor') {
1056
+ this.pendingToolConfirmationDetails.delete(callId);
1057
+ this.pendingCorrelationIds.delete(callId);
1058
+ }
1059
+
1060
+ // If outcome is Cancel, scheduler should update status to 'cancelled', which then resolves the tool.
1061
+ // If ProceedOnce, scheduler updates to 'executing', then eventually 'success'/'error', which resolves.
1062
+ return true;
1063
+ } catch (error) {
1064
+ logger.error(
1065
+ `[Task] Error during tool confirmation for callId ${callId}:`,
1066
+ error,
1067
+ );
1068
+ // If confirming fails, we should probably mark this tool as failed
1069
+ this._resolveToolCall(callId); // Resolve it as it won't proceed.
1070
+ const errorMessageText =
1071
+ error instanceof Error
1072
+ ? error.message
1073
+ : `Error processing tool confirmation for ${callId}`;
1074
+ const message = this._createTextMessage(errorMessageText);
1075
+ const toolCallUpdate: ToolCallUpdate = {
1076
+ kind: CoderAgentEvent.ToolCallUpdateEvent,
1077
+ };
1078
+ const event = this._createStatusUpdateEvent(
1079
+ this.taskState,
1080
+ toolCallUpdate,
1081
+ message,
1082
+ false,
1083
+ );
1084
+ this.eventBus?.publish(event);
1085
+ return false;
1086
+ }
1087
+ }
1088
+
1089
+ getAndClearCompletedTools(): CompletedToolCall[] {
1090
+ const tools = [...this.completedToolCalls];
1091
+ for (const tool of tools) {
1092
+ this.processedToolCallIds.add(tool.request.callId);
1093
+ }
1094
+ this.completedToolCalls = [];
1095
+ return tools;
1096
+ }
1097
+
1098
+ addToolResponsesToHistory(completedTools: CompletedToolCall[]): void {
1099
+ logger.info(
1100
+ `[Task] Adding ${completedTools.length} tool responses to history without generating a new response.`,
1101
+ );
1102
+ const parts: genAiPart[] = [];
1103
+ for (const toolCall of completedTools) {
1104
+ const response = toolCall.response?.responseParts;
1105
+ if (!response) {
1106
+ continue;
1107
+ }
1108
+ if (Array.isArray(response)) {
1109
+ parts.push(...response);
1110
+ } else if (typeof response === 'string') {
1111
+ parts.push({ text: response });
1112
+ } else {
1113
+ parts.push(response);
1114
+ }
1115
+ }
1116
+ if (parts.length > 0) {
1117
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
1118
+ this.geminiClient.addHistory({
1119
+ role: 'user',
1120
+ parts,
1121
+ });
1122
+ }
1123
+ }
1124
+
1125
+ async *sendCompletedToolsToLlm(
1126
+ completedToolCalls: CompletedToolCall[],
1127
+ aborted: AbortSignal,
1128
+ ): AsyncGenerator<ServerGeminiStreamEvent> {
1129
+ if (completedToolCalls.length === 0) {
1130
+ yield* (async function* () {})(); // Yield nothing
1131
+ return;
1132
+ }
1133
+
1134
+ const llmParts: PartUnion[] = [];
1135
+ logger.info(
1136
+ `[Task] Feeding ${completedToolCalls.length} tool responses to LLM.`,
1137
+ );
1138
+ for (const completedToolCall of completedToolCalls) {
1139
+ logger.info(
1140
+ `[Task] Adding tool response for "${completedToolCall.request.name}" (callId: ${completedToolCall.request.callId}) to LLM input.`,
1141
+ );
1142
+ const responseParts = completedToolCall.response.responseParts;
1143
+ if (Array.isArray(responseParts)) {
1144
+ llmParts.push(...responseParts);
1145
+ } else {
1146
+ llmParts.push(responseParts);
1147
+ }
1148
+ }
1149
+
1150
+ logger.info('[Task] Sending new parts to agent.');
1151
+ const stateChange: StateChange = {
1152
+ kind: CoderAgentEvent.StateChangeEvent,
1153
+ };
1154
+ // Set task state to working as we are about to call LLM
1155
+ this.setTaskStateAndPublishUpdate('working', stateChange);
1156
+ this.currentAgentMessageId = uuidv4();
1157
+ yield* this.geminiClient.sendMessageStream(
1158
+ llmParts,
1159
+ aborted,
1160
+ completedToolCalls[0]?.request.prompt_id ?? '',
1161
+ );
1162
+ }
1163
+
1164
+ async *acceptUserMessage(
1165
+ requestContext: RequestContext,
1166
+ aborted: AbortSignal,
1167
+ ): AsyncGenerator<ServerGeminiStreamEvent> {
1168
+ // Clear any stale cancellation error from a previous aborted execution loop
1169
+ this.cancellationError = undefined;
1170
+
1171
+ const userMessage = requestContext.userMessage;
1172
+ const llmParts: PartUnion[] = [];
1173
+ let anyConfirmationHandled = false;
1174
+ let hasContentForLlm = false;
1175
+
1176
+ for (const part of userMessage.parts) {
1177
+ const confirmationHandled = await this._handleToolConfirmationPart(part);
1178
+ if (confirmationHandled) {
1179
+ anyConfirmationHandled = true;
1180
+ // If a confirmation was handled, the scheduler will now run the tool (or cancel it).
1181
+ // We don't send anything to the LLM for this part.
1182
+ // The subsequent tool execution will eventually lead to resolveToolCall.
1183
+ continue;
1184
+ }
1185
+
1186
+ if (part.kind === 'text') {
1187
+ llmParts.push({ text: part.text });
1188
+ hasContentForLlm = true;
1189
+ }
1190
+ }
1191
+
1192
+ if (hasContentForLlm) {
1193
+ this.currentPromptId =
1194
+ this.config.getSessionId() + '########' + this.promptCount++;
1195
+ this.currentAgentMessageId = uuidv4();
1196
+ logger.info('[Task] Sending new parts to LLM.');
1197
+ const stateChange: StateChange = {
1198
+ kind: CoderAgentEvent.StateChangeEvent,
1199
+ };
1200
+ // Set task state to working as we are about to call LLM
1201
+ this.setTaskStateAndPublishUpdate('working', stateChange);
1202
+ yield* this.geminiClient.sendMessageStream(
1203
+ llmParts,
1204
+ aborted,
1205
+ this.currentPromptId,
1206
+ );
1207
+ } else if (anyConfirmationHandled) {
1208
+ logger.info(
1209
+ '[Task] User message only contained tool confirmations. Scheduler is active. No new input for LLM this turn.',
1210
+ );
1211
+ // Ensure task state reflects that scheduler might be working due to confirmation.
1212
+ // If scheduler is active, it will emit its own status updates.
1213
+ // If all pending tools were just confirmed, waitForPendingTools will handle the wait.
1214
+ // If some tools are still pending approval, scheduler would have set InputRequired.
1215
+ // If not, and no new text, we are just waiting.
1216
+ if (
1217
+ this.pendingToolCalls.size > 0 &&
1218
+ this.taskState !== 'input-required'
1219
+ ) {
1220
+ const stateChange: StateChange = {
1221
+ kind: CoderAgentEvent.StateChangeEvent,
1222
+ };
1223
+ this.setTaskStateAndPublishUpdate('working', stateChange); // Reflect potential background activity
1224
+ }
1225
+ yield* (async function* () {})(); // Yield nothing
1226
+ } else {
1227
+ logger.info(
1228
+ '[Task] No relevant parts in user message for LLM interaction or tool confirmation.',
1229
+ );
1230
+ // If there's no new text and no confirmations, and no pending tools,
1231
+ // it implies we might need to signal input required if nothing else is happening.
1232
+ // However, the agent.ts will make this determination after waitForPendingTools.
1233
+ yield* (async function* () {})(); // Yield nothing
1234
+ }
1235
+ }
1236
+
1237
+ _sendTextContent(content: string, traceId?: string): void {
1238
+ if (content === '') {
1239
+ return;
1240
+ }
1241
+ const message = this._createTextMessage(content);
1242
+ const textContent: TextContent = {
1243
+ kind: CoderAgentEvent.TextContentEvent,
1244
+ };
1245
+ this.eventBus?.publish(
1246
+ this._createStatusUpdateEvent(
1247
+ this.taskState,
1248
+ textContent,
1249
+ message,
1250
+ false,
1251
+ undefined,
1252
+ undefined,
1253
+ traceId,
1254
+ ),
1255
+ );
1256
+ }
1257
+
1258
+ _sendThought(content: ThoughtSummary, traceId?: string): void {
1259
+ if (!content.subject && !content.description) {
1260
+ return;
1261
+ }
1262
+ logger.info('[Task] Sending thought to event bus.');
1263
+ const message: Message = {
1264
+ kind: 'message',
1265
+ role: 'agent',
1266
+ parts: [
1267
+ {
1268
+ kind: 'data',
1269
+ data: content,
1270
+ } as Part,
1271
+ ],
1272
+ messageId: this.currentAgentMessageId,
1273
+ taskId: this.id,
1274
+ contextId: this.contextId,
1275
+ };
1276
+ const thought: Thought = {
1277
+ kind: CoderAgentEvent.ThoughtEvent,
1278
+ };
1279
+ this.eventBus?.publish(
1280
+ this._createStatusUpdateEvent(
1281
+ this.taskState,
1282
+ thought,
1283
+ message,
1284
+ false,
1285
+ undefined,
1286
+ undefined,
1287
+ traceId,
1288
+ ),
1289
+ );
1290
+ }
1291
+
1292
+ _sendCitation(citation: string) {
1293
+ if (!citation || citation.trim() === '') {
1294
+ return;
1295
+ }
1296
+ logger.info('[Task] Sending citation to event bus.');
1297
+ const message = this._createTextMessage(citation);
1298
+ const citationEvent: Citation = {
1299
+ kind: CoderAgentEvent.CitationEvent,
1300
+ };
1301
+ this.eventBus?.publish(
1302
+ this._createStatusUpdateEvent(this.taskState, citationEvent, message),
1303
+ );
1304
+ }
1305
+ }
packages/a2a-server/src/commands/command-registry.test.ts ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+ import type { Command } from './types.js';
9
+
10
+ const {
11
+ mockExtensionsCommand,
12
+ mockListExtensionsCommand,
13
+ mockExtensionsCommandInstance,
14
+ mockListExtensionsCommandInstance,
15
+ } = vi.hoisted(() => {
16
+ const listInstance: Command = {
17
+ name: 'extensions list',
18
+ description: 'Lists all installed extensions.',
19
+ execute: vi.fn(),
20
+ };
21
+
22
+ const extInstance: Command = {
23
+ name: 'extensions',
24
+ description: 'Manage extensions.',
25
+ execute: vi.fn(),
26
+ subCommands: [listInstance],
27
+ };
28
+
29
+ return {
30
+ mockListExtensionsCommandInstance: listInstance,
31
+ mockExtensionsCommandInstance: extInstance,
32
+ mockExtensionsCommand: vi.fn(() => extInstance),
33
+ mockListExtensionsCommand: vi.fn(() => listInstance),
34
+ };
35
+ });
36
+
37
+ vi.mock('./extensions.js', () => ({
38
+ ExtensionsCommand: mockExtensionsCommand,
39
+ ListExtensionsCommand: mockListExtensionsCommand,
40
+ }));
41
+
42
+ vi.mock('./init.js', () => ({
43
+ InitCommand: vi.fn(() => ({
44
+ name: 'init',
45
+ description: 'Initializes the server.',
46
+ execute: vi.fn(),
47
+ })),
48
+ }));
49
+
50
+ vi.mock('./restore.js', () => ({
51
+ RestoreCommand: vi.fn(() => ({
52
+ name: 'restore',
53
+ description: 'Restores the server.',
54
+ execute: vi.fn(),
55
+ })),
56
+ }));
57
+
58
+ import { commandRegistry } from './command-registry.js';
59
+
60
+ describe('CommandRegistry', () => {
61
+ beforeEach(async () => {
62
+ vi.clearAllMocks();
63
+ commandRegistry.initialize();
64
+ });
65
+
66
+ it('should register ExtensionsCommand on initialization', async () => {
67
+ expect(mockExtensionsCommand).toHaveBeenCalled();
68
+ const command = commandRegistry.get('extensions');
69
+ expect(command).toBe(mockExtensionsCommandInstance);
70
+ }, 20000);
71
+
72
+ it('should register sub commands on initialization', async () => {
73
+ const command = commandRegistry.get('extensions list');
74
+ expect(command).toBe(mockListExtensionsCommandInstance);
75
+ });
76
+
77
+ it('get() should return undefined for a non-existent command', async () => {
78
+ const command = commandRegistry.get('non-existent');
79
+ expect(command).toBeUndefined();
80
+ });
81
+
82
+ it('register() should register a new command', async () => {
83
+ const mockCommand: Command = {
84
+ name: 'test-command',
85
+ description: '',
86
+ execute: vi.fn(),
87
+ };
88
+ commandRegistry.register(mockCommand);
89
+ const command = commandRegistry.get('test-command');
90
+ expect(command).toBe(mockCommand);
91
+ });
92
+
93
+ it('register() should register a nested command', async () => {
94
+ const mockSubSubCommand: Command = {
95
+ name: 'test-command-sub-sub',
96
+ description: '',
97
+ execute: vi.fn(),
98
+ };
99
+ const mockSubCommand: Command = {
100
+ name: 'test-command-sub',
101
+ description: '',
102
+ execute: vi.fn(),
103
+ subCommands: [mockSubSubCommand],
104
+ };
105
+ const mockCommand: Command = {
106
+ name: 'test-command',
107
+ description: '',
108
+ execute: vi.fn(),
109
+ subCommands: [mockSubCommand],
110
+ };
111
+ commandRegistry.register(mockCommand);
112
+
113
+ const command = commandRegistry.get('test-command');
114
+ const subCommand = commandRegistry.get('test-command-sub');
115
+ const subSubCommand = commandRegistry.get('test-command-sub-sub');
116
+
117
+ expect(command).toBe(mockCommand);
118
+ expect(subCommand).toBe(mockSubCommand);
119
+ expect(subSubCommand).toBe(mockSubSubCommand);
120
+ });
121
+
122
+ it('register() should not enter an infinite loop with a cyclic command', async () => {
123
+ const { debugLogger } = await import('@google/gemini-cli-core');
124
+ const warnSpy = vi.spyOn(debugLogger, 'warn').mockImplementation(() => {});
125
+ const mockCommand: Command = {
126
+ name: 'cyclic-command',
127
+ description: '',
128
+ subCommands: [],
129
+ execute: vi.fn(),
130
+ };
131
+
132
+ mockCommand.subCommands?.push(mockCommand); // Create cycle
133
+
134
+ commandRegistry.register(mockCommand);
135
+
136
+ expect(commandRegistry.get('cyclic-command')).toBe(mockCommand);
137
+ expect(warnSpy).toHaveBeenCalledWith(
138
+ 'Command cyclic-command already registered. Skipping.',
139
+ );
140
+ warnSpy.mockRestore();
141
+ });
142
+ });
packages/a2a-server/src/commands/command-registry.ts ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { MemoryCommand } from './memory.js';
8
+ import { debugLogger } from '@google/gemini-cli-core';
9
+ import { ExtensionsCommand } from './extensions.js';
10
+ import { InitCommand } from './init.js';
11
+ import { RestoreCommand } from './restore.js';
12
+ import type { Command } from './types.js';
13
+
14
+ export class CommandRegistry {
15
+ private readonly commands = new Map<string, Command>();
16
+
17
+ constructor() {
18
+ this.initialize();
19
+ }
20
+
21
+ initialize() {
22
+ this.commands.clear();
23
+ this.register(new ExtensionsCommand());
24
+ this.register(new RestoreCommand());
25
+ this.register(new InitCommand());
26
+ this.register(new MemoryCommand());
27
+ }
28
+
29
+ register(command: Command) {
30
+ if (this.commands.has(command.name)) {
31
+ debugLogger.warn(`Command ${command.name} already registered. Skipping.`);
32
+ return;
33
+ }
34
+
35
+ this.commands.set(command.name, command);
36
+
37
+ for (const subCommand of command.subCommands ?? []) {
38
+ this.register(subCommand);
39
+ }
40
+ }
41
+
42
+ get(commandName: string): Command | undefined {
43
+ return this.commands.get(commandName);
44
+ }
45
+
46
+ getAllCommands(): Command[] {
47
+ return [...this.commands.values()];
48
+ }
49
+ }
50
+
51
+ export const commandRegistry = new CommandRegistry();
packages/a2a-server/src/commands/extensions.test.ts ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi } from 'vitest';
8
+ import { ExtensionsCommand, ListExtensionsCommand } from './extensions.js';
9
+ import type { CommandContext } from './types.js';
10
+
11
+ const mockListExtensions = vi.hoisted(() => vi.fn());
12
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
13
+ const original =
14
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
15
+
16
+ return {
17
+ ...original,
18
+ listExtensions: mockListExtensions,
19
+ };
20
+ });
21
+
22
+ describe('ExtensionsCommand', () => {
23
+ it('should have the correct name', () => {
24
+ const command = new ExtensionsCommand();
25
+ expect(command.name).toEqual('extensions');
26
+ });
27
+
28
+ it('should have the correct description', () => {
29
+ const command = new ExtensionsCommand();
30
+ expect(command.description).toEqual('Manage extensions.');
31
+ });
32
+
33
+ it('should have "extensions list" as a subcommand', () => {
34
+ const command = new ExtensionsCommand();
35
+ expect(command.subCommands.map((c) => c.name)).toContain('extensions list');
36
+ });
37
+
38
+ it('should be a top-level command', () => {
39
+ const command = new ExtensionsCommand();
40
+ expect(command.topLevel).toBe(true);
41
+ });
42
+
43
+ it('should default to listing extensions', async () => {
44
+ const command = new ExtensionsCommand();
45
+ const mockConfig = { config: {} } as CommandContext;
46
+ const mockExtensions = [{ name: 'ext1' }];
47
+ mockListExtensions.mockReturnValue(mockExtensions);
48
+
49
+ const result = await command.execute(mockConfig, []);
50
+
51
+ expect(result).toEqual({ name: 'extensions list', data: mockExtensions });
52
+ expect(mockListExtensions).toHaveBeenCalledWith(mockConfig.config);
53
+ });
54
+ });
55
+
56
+ describe('ListExtensionsCommand', () => {
57
+ it('should have the correct name', () => {
58
+ const command = new ListExtensionsCommand();
59
+ expect(command.name).toEqual('extensions list');
60
+ });
61
+
62
+ it('should call listExtensions with the provided config', async () => {
63
+ const command = new ListExtensionsCommand();
64
+ const mockConfig = { config: {} } as CommandContext;
65
+ const mockExtensions = [{ name: 'ext1' }];
66
+ mockListExtensions.mockReturnValue(mockExtensions);
67
+
68
+ const result = await command.execute(mockConfig, []);
69
+
70
+ expect(result).toEqual({ name: 'extensions list', data: mockExtensions });
71
+ expect(mockListExtensions).toHaveBeenCalledWith(mockConfig.config);
72
+ });
73
+
74
+ it('should return a message when no extensions are installed', async () => {
75
+ const command = new ListExtensionsCommand();
76
+ const mockConfig = { config: {} } as CommandContext;
77
+ mockListExtensions.mockReturnValue([]);
78
+
79
+ const result = await command.execute(mockConfig, []);
80
+
81
+ expect(result).toEqual({
82
+ name: 'extensions list',
83
+ data: 'No extensions installed.',
84
+ });
85
+ expect(mockListExtensions).toHaveBeenCalledWith(mockConfig.config);
86
+ });
87
+ });
packages/a2a-server/src/commands/extensions.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { listExtensions } from '@google/gemini-cli-core';
8
+ import type {
9
+ Command,
10
+ CommandContext,
11
+ CommandExecutionResponse,
12
+ } from './types.js';
13
+
14
+ export class ExtensionsCommand implements Command {
15
+ readonly name = 'extensions';
16
+ readonly description = 'Manage extensions.';
17
+ readonly subCommands = [new ListExtensionsCommand()];
18
+ readonly topLevel = true;
19
+
20
+ async execute(
21
+ context: CommandContext,
22
+ _: string[],
23
+ ): Promise<CommandExecutionResponse> {
24
+ return new ListExtensionsCommand().execute(context, _);
25
+ }
26
+ }
27
+
28
+ export class ListExtensionsCommand implements Command {
29
+ readonly name = 'extensions list';
30
+ readonly description = 'Lists all installed extensions.';
31
+
32
+ async execute(
33
+ context: CommandContext,
34
+ _: string[],
35
+ ): Promise<CommandExecutionResponse> {
36
+ const extensions = listExtensions(context.config);
37
+ const data = extensions.length ? extensions : 'No extensions installed.';
38
+
39
+ return { name: this.name, data };
40
+ }
41
+ }
packages/a2a-server/src/commands/init.test.ts ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+ import { InitCommand } from './init.js';
9
+ import {
10
+ performInit,
11
+ type CommandActionReturn,
12
+ type Config,
13
+ } from '@google/gemini-cli-core';
14
+ import * as fs from 'node:fs';
15
+ import * as path from 'node:path';
16
+ import { CoderAgentExecutor } from '../agent/executor.js';
17
+ import { CoderAgentEvent } from '../types.js';
18
+ import type { ExecutionEventBus } from '@a2a-js/sdk/server';
19
+ import { createMockConfig } from '../utils/testing_utils.js';
20
+ import type { CommandContext } from './types.js';
21
+ import { logger } from '../utils/logger.js';
22
+
23
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
24
+ const actual =
25
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
26
+ return {
27
+ ...actual,
28
+ performInit: vi.fn(),
29
+ };
30
+ });
31
+
32
+ vi.mock('node:fs', async (importOriginal) => {
33
+ const actual = await importOriginal<typeof import('node:fs')>();
34
+ return {
35
+ ...actual,
36
+ existsSync: vi.fn(),
37
+ writeFileSync: vi.fn(),
38
+ };
39
+ });
40
+
41
+ vi.mock('../agent/executor.js', () => ({
42
+ CoderAgentExecutor: vi.fn().mockImplementation(() => ({
43
+ execute: vi.fn(),
44
+ })),
45
+ }));
46
+
47
+ vi.mock('../utils/logger.js', () => ({
48
+ logger: {
49
+ info: vi.fn(),
50
+ error: vi.fn(),
51
+ },
52
+ }));
53
+
54
+ describe('InitCommand', () => {
55
+ let eventBus: ExecutionEventBus;
56
+ let command: InitCommand;
57
+ let context: CommandContext;
58
+ let publishSpy: ReturnType<typeof vi.spyOn>;
59
+ let mockExecute: ReturnType<typeof vi.fn>;
60
+ const mockWorkspacePath = path.resolve('/tmp');
61
+
62
+ beforeEach(() => {
63
+ vi.stubEnv('CODER_AGENT_WORKSPACE_PATH', mockWorkspacePath);
64
+ eventBus = {
65
+ publish: vi.fn(),
66
+ } as unknown as ExecutionEventBus;
67
+ command = new InitCommand();
68
+ const mockConfig = createMockConfig({
69
+ getModel: () => 'gemini-pro',
70
+ });
71
+ const mockExecutorInstance = new CoderAgentExecutor();
72
+ context = {
73
+ config: mockConfig as unknown as Config,
74
+ agentExecutor: mockExecutorInstance,
75
+ eventBus,
76
+ } as CommandContext;
77
+ publishSpy = vi.spyOn(eventBus, 'publish');
78
+ mockExecute = vi.fn();
79
+ vi.spyOn(mockExecutorInstance, 'execute').mockImplementation(mockExecute);
80
+ vi.clearAllMocks();
81
+ });
82
+
83
+ afterEach(() => {
84
+ vi.unstubAllEnvs();
85
+ });
86
+
87
+ it('has requiresWorkspace set to true', () => {
88
+ expect(command.requiresWorkspace).toBe(true);
89
+ });
90
+
91
+ describe('execute', () => {
92
+ it('handles info from performInit', async () => {
93
+ vi.mocked(performInit).mockReturnValue({
94
+ type: 'message',
95
+ messageType: 'info',
96
+ content: 'GEMINI.md already exists.',
97
+ } as CommandActionReturn);
98
+
99
+ await command.execute(context, []);
100
+
101
+ expect(logger.info).toHaveBeenCalledWith(
102
+ '[EventBus event]: ',
103
+ expect.objectContaining({
104
+ kind: 'status-update',
105
+ status: expect.objectContaining({
106
+ state: 'completed',
107
+ message: expect.objectContaining({
108
+ parts: [{ kind: 'text', text: 'GEMINI.md already exists.' }],
109
+ }),
110
+ }),
111
+ }),
112
+ );
113
+
114
+ expect(publishSpy).toHaveBeenCalledWith(
115
+ expect.objectContaining({
116
+ kind: 'status-update',
117
+ status: expect.objectContaining({
118
+ state: 'completed',
119
+ message: expect.objectContaining({
120
+ parts: [{ kind: 'text', text: 'GEMINI.md already exists.' }],
121
+ }),
122
+ }),
123
+ }),
124
+ );
125
+ });
126
+
127
+ it('handles error from performInit', async () => {
128
+ vi.mocked(performInit).mockReturnValue({
129
+ type: 'message',
130
+ messageType: 'error',
131
+ content: 'An error occurred.',
132
+ } as CommandActionReturn);
133
+
134
+ await command.execute(context, []);
135
+
136
+ expect(publishSpy).toHaveBeenCalledWith(
137
+ expect.objectContaining({
138
+ kind: 'status-update',
139
+ status: expect.objectContaining({
140
+ state: 'failed',
141
+ message: expect.objectContaining({
142
+ parts: [{ kind: 'text', text: 'An error occurred.' }],
143
+ }),
144
+ }),
145
+ }),
146
+ );
147
+ });
148
+
149
+ describe('when handling submit_prompt', () => {
150
+ beforeEach(() => {
151
+ vi.mocked(performInit).mockReturnValue({
152
+ type: 'submit_prompt',
153
+ content: 'Create a new GEMINI.md file.',
154
+ } as CommandActionReturn);
155
+ });
156
+
157
+ it('writes the file and executes the agent', async () => {
158
+ await command.execute(context, []);
159
+
160
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
161
+ path.join(mockWorkspacePath, 'GEMINI.md'),
162
+ '',
163
+ 'utf8',
164
+ );
165
+ expect(mockExecute).toHaveBeenCalled();
166
+ });
167
+
168
+ it('passes autoExecute to the agent executor', async () => {
169
+ await command.execute(context, []);
170
+
171
+ expect(mockExecute).toHaveBeenCalledWith(
172
+ expect.objectContaining({
173
+ userMessage: expect.objectContaining({
174
+ parts: expect.arrayContaining([
175
+ expect.objectContaining({
176
+ text: 'Create a new GEMINI.md file.',
177
+ }),
178
+ ]),
179
+ metadata: {
180
+ coderAgent: {
181
+ kind: CoderAgentEvent.StateAgentSettingsEvent,
182
+ workspacePath: mockWorkspacePath,
183
+ autoExecute: true,
184
+ },
185
+ },
186
+ }),
187
+ }),
188
+ eventBus,
189
+ );
190
+ });
191
+ });
192
+ });
193
+ });
packages/a2a-server/src/commands/init.ts ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import { CoderAgentEvent, type AgentSettings } from '../types.js';
10
+ import { performInit } from '@google/gemini-cli-core';
11
+ import type {
12
+ Command,
13
+ CommandContext,
14
+ CommandExecutionResponse,
15
+ } from './types.js';
16
+ import type { CoderAgentExecutor } from '../agent/executor.js';
17
+ import type {
18
+ ExecutionEventBus,
19
+ RequestContext,
20
+ AgentExecutionEvent,
21
+ } from '@a2a-js/sdk/server';
22
+ import { v4 as uuidv4 } from 'uuid';
23
+ import { logger } from '../utils/logger.js';
24
+
25
+ export class InitCommand implements Command {
26
+ name = 'init';
27
+ description = 'Analyzes the project and creates a tailored GEMINI.md file';
28
+ requiresWorkspace = true;
29
+ streaming = true;
30
+
31
+ private handleMessageResult(
32
+ result: { content: string; messageType: 'info' | 'error' },
33
+ context: CommandContext,
34
+ eventBus: ExecutionEventBus,
35
+ taskId: string,
36
+ contextId: string,
37
+ ): CommandExecutionResponse {
38
+ const statusState = result.messageType === 'error' ? 'failed' : 'completed';
39
+ const eventType =
40
+ result.messageType === 'error'
41
+ ? CoderAgentEvent.StateChangeEvent
42
+ : CoderAgentEvent.TextContentEvent;
43
+
44
+ const event: AgentExecutionEvent = {
45
+ kind: 'status-update',
46
+ taskId,
47
+ contextId,
48
+ status: {
49
+ state: statusState,
50
+ message: {
51
+ kind: 'message',
52
+ role: 'agent',
53
+ parts: [{ kind: 'text', text: result.content }],
54
+ messageId: uuidv4(),
55
+ taskId,
56
+ contextId,
57
+ },
58
+ timestamp: new Date().toISOString(),
59
+ },
60
+ final: true,
61
+ metadata: {
62
+ coderAgent: { kind: eventType },
63
+ model: context.config.getModel(),
64
+ },
65
+ };
66
+
67
+ logger.info('[EventBus event]: ', event);
68
+ eventBus.publish(event);
69
+ return {
70
+ name: this.name,
71
+ data: result,
72
+ };
73
+ }
74
+
75
+ private async handleSubmitPromptResult(
76
+ result: { content: unknown },
77
+ context: CommandContext,
78
+ geminiMdPath: string,
79
+ eventBus: ExecutionEventBus,
80
+ taskId: string,
81
+ contextId: string,
82
+ ): Promise<CommandExecutionResponse> {
83
+ fs.writeFileSync(geminiMdPath, '', 'utf8');
84
+
85
+ if (!context.agentExecutor) {
86
+ throw new Error('Agent executor not found in context.');
87
+ }
88
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
89
+ const agentExecutor = context.agentExecutor as CoderAgentExecutor;
90
+
91
+ const agentSettings: AgentSettings = {
92
+ kind: CoderAgentEvent.StateAgentSettingsEvent,
93
+ workspacePath: process.env['CODER_AGENT_WORKSPACE_PATH']!,
94
+ autoExecute: true,
95
+ };
96
+
97
+ if (typeof result.content !== 'string') {
98
+ throw new Error('Init command content must be a string.');
99
+ }
100
+ const promptText = result.content;
101
+
102
+ const requestContext: RequestContext = {
103
+ userMessage: {
104
+ kind: 'message',
105
+ role: 'user',
106
+ parts: [{ kind: 'text', text: promptText }],
107
+ messageId: uuidv4(),
108
+ taskId,
109
+ contextId,
110
+ metadata: {
111
+ coderAgent: agentSettings,
112
+ },
113
+ },
114
+ taskId,
115
+ contextId,
116
+ };
117
+
118
+ // The executor will handle the entire agentic loop, including
119
+ // creating the task, streaming responses, and handling tools.
120
+ await agentExecutor.execute(requestContext, eventBus);
121
+ return {
122
+ name: this.name,
123
+ data: geminiMdPath,
124
+ };
125
+ }
126
+
127
+ async execute(
128
+ context: CommandContext,
129
+ _args: string[] = [],
130
+ ): Promise<CommandExecutionResponse> {
131
+ if (!context.eventBus) {
132
+ return {
133
+ name: this.name,
134
+ data: 'Use executeStream to get streaming results.',
135
+ };
136
+ }
137
+
138
+ const geminiMdPath = path.join(
139
+ process.env['CODER_AGENT_WORKSPACE_PATH']!,
140
+ 'GEMINI.md',
141
+ );
142
+ const result = performInit(fs.existsSync(geminiMdPath));
143
+
144
+ const taskId = uuidv4();
145
+ const contextId = uuidv4();
146
+
147
+ switch (result.type) {
148
+ case 'message':
149
+ return this.handleMessageResult(
150
+ result,
151
+ context,
152
+ context.eventBus,
153
+ taskId,
154
+ contextId,
155
+ );
156
+ case 'submit_prompt':
157
+ return this.handleSubmitPromptResult(
158
+ result,
159
+ context,
160
+ geminiMdPath,
161
+ context.eventBus,
162
+ taskId,
163
+ contextId,
164
+ );
165
+ default:
166
+ throw new Error('Unknown result type from performInit');
167
+ }
168
+ }
169
+ }
packages/a2a-server/src/commands/memory.test.ts ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ listMemoryFiles,
9
+ refreshMemory,
10
+ showMemory,
11
+ type Config,
12
+ } from '@google/gemini-cli-core';
13
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
14
+ import {
15
+ ListMemoryCommand,
16
+ MemoryCommand,
17
+ RefreshMemoryCommand,
18
+ ShowMemoryCommand,
19
+ } from './memory.js';
20
+ import type { CommandContext } from './types.js';
21
+
22
+ // Mock the core functions
23
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
24
+ const actual =
25
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
26
+ return {
27
+ ...actual,
28
+ showMemory: vi.fn(),
29
+ refreshMemory: vi.fn(),
30
+ listMemoryFiles: vi.fn(),
31
+ };
32
+ });
33
+
34
+ const mockShowMemory = vi.mocked(showMemory);
35
+ const mockRefreshMemory = vi.mocked(refreshMemory);
36
+ const mockListMemoryFiles = vi.mocked(listMemoryFiles);
37
+
38
+ describe('a2a-server memory commands', () => {
39
+ let mockContext: CommandContext;
40
+ let mockConfig: Config;
41
+
42
+ beforeEach(() => {
43
+ mockConfig = {} as unknown as Config;
44
+
45
+ mockContext = {
46
+ config: mockConfig,
47
+ };
48
+ });
49
+
50
+ describe('MemoryCommand', () => {
51
+ it('delegates to ShowMemoryCommand', async () => {
52
+ const command = new MemoryCommand();
53
+ mockShowMemory.mockReturnValue({
54
+ type: 'message',
55
+ messageType: 'info',
56
+ content: 'showing memory',
57
+ });
58
+ const response = await command.execute(mockContext, []);
59
+ expect(response.data).toBe('showing memory');
60
+ expect(mockShowMemory).toHaveBeenCalledWith(mockContext.config);
61
+ });
62
+ });
63
+
64
+ describe('ShowMemoryCommand', () => {
65
+ it('executes showMemory and returns the content', async () => {
66
+ const command = new ShowMemoryCommand();
67
+ mockShowMemory.mockReturnValue({
68
+ type: 'message',
69
+ messageType: 'info',
70
+ content: 'test memory content',
71
+ });
72
+
73
+ const response = await command.execute(mockContext, []);
74
+
75
+ expect(mockShowMemory).toHaveBeenCalledWith(mockContext.config);
76
+ expect(response.name).toBe('memory show');
77
+ expect(response.data).toBe('test memory content');
78
+ });
79
+ });
80
+
81
+ describe('RefreshMemoryCommand', () => {
82
+ it('executes refreshMemory and returns the content', async () => {
83
+ const command = new RefreshMemoryCommand();
84
+ mockRefreshMemory.mockResolvedValue({
85
+ type: 'message',
86
+ messageType: 'info',
87
+ content: 'memory refreshed',
88
+ });
89
+
90
+ const response = await command.execute(mockContext, []);
91
+
92
+ expect(mockRefreshMemory).toHaveBeenCalledWith(mockContext.config);
93
+ expect(response.name).toBe('memory refresh');
94
+ expect(response.data).toBe('memory refreshed');
95
+ });
96
+ });
97
+
98
+ describe('ListMemoryCommand', () => {
99
+ it('executes listMemoryFiles and returns the content', async () => {
100
+ const command = new ListMemoryCommand();
101
+ mockListMemoryFiles.mockReturnValue({
102
+ type: 'message',
103
+ messageType: 'info',
104
+ content: 'file1.md\nfile2.md',
105
+ });
106
+
107
+ const response = await command.execute(mockContext, []);
108
+
109
+ expect(mockListMemoryFiles).toHaveBeenCalledWith(mockContext.config);
110
+ expect(response.name).toBe('memory list');
111
+ expect(response.data).toBe('file1.md\nfile2.md');
112
+ });
113
+ });
114
+ });
packages/a2a-server/src/commands/memory.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ listMemoryFiles,
9
+ refreshMemory,
10
+ showMemory,
11
+ } from '@google/gemini-cli-core';
12
+ import type {
13
+ Command,
14
+ CommandContext,
15
+ CommandExecutionResponse,
16
+ } from './types.js';
17
+
18
+ export class MemoryCommand implements Command {
19
+ readonly name = 'memory';
20
+ readonly description = 'Manage memory.';
21
+ readonly subCommands = [
22
+ new ShowMemoryCommand(),
23
+ new RefreshMemoryCommand(),
24
+ new ListMemoryCommand(),
25
+ ];
26
+ readonly topLevel = true;
27
+ readonly requiresWorkspace = true;
28
+
29
+ async execute(
30
+ context: CommandContext,
31
+ _: string[],
32
+ ): Promise<CommandExecutionResponse> {
33
+ return new ShowMemoryCommand().execute(context, _);
34
+ }
35
+ }
36
+
37
+ export class ShowMemoryCommand implements Command {
38
+ readonly name = 'memory show';
39
+ readonly description = 'Shows the current memory contents.';
40
+
41
+ async execute(
42
+ context: CommandContext,
43
+ _: string[],
44
+ ): Promise<CommandExecutionResponse> {
45
+ const result = showMemory(context.config);
46
+ return { name: this.name, data: result.content };
47
+ }
48
+ }
49
+
50
+ export class RefreshMemoryCommand implements Command {
51
+ readonly name = 'memory refresh';
52
+ readonly description = 'Refreshes the memory from the source.';
53
+
54
+ async execute(
55
+ context: CommandContext,
56
+ _: string[],
57
+ ): Promise<CommandExecutionResponse> {
58
+ const result = await refreshMemory(context.config);
59
+ return { name: this.name, data: result.content };
60
+ }
61
+ }
62
+
63
+ export class ListMemoryCommand implements Command {
64
+ readonly name = 'memory list';
65
+ readonly description = 'Lists the paths of the GEMINI.md files in use.';
66
+
67
+ async execute(
68
+ context: CommandContext,
69
+ _: string[],
70
+ ): Promise<CommandExecutionResponse> {
71
+ const result = listMemoryFiles(context.config);
72
+ return { name: this.name, data: result.content };
73
+ }
74
+ }
packages/a2a-server/src/commands/restore.test.ts ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+ import { RestoreCommand, ListCheckpointsCommand } from './restore.js';
9
+ import type { CommandContext } from './types.js';
10
+ import type { Config } from '@google/gemini-cli-core';
11
+ import { createMockConfig } from '../utils/testing_utils.js';
12
+
13
+ beforeEach(() => {
14
+ vi.clearAllMocks();
15
+ });
16
+
17
+ const mockPerformRestore = vi.hoisted(() => vi.fn());
18
+ const mockLoggerInfo = vi.hoisted(() => vi.fn());
19
+ const mockGetCheckpointInfoList = vi.hoisted(() => vi.fn());
20
+
21
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
22
+ const original =
23
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
24
+ return {
25
+ ...original,
26
+ performRestore: mockPerformRestore,
27
+ getCheckpointInfoList: mockGetCheckpointInfoList,
28
+ };
29
+ });
30
+
31
+ const mockFs = vi.hoisted(() => ({
32
+ readFile: vi.fn(),
33
+ readdir: vi.fn(),
34
+ mkdir: vi.fn(),
35
+ }));
36
+
37
+ vi.mock('node:fs/promises', () => mockFs);
38
+
39
+ vi.mock('../utils/logger.js', () => ({
40
+ logger: {
41
+ info: mockLoggerInfo,
42
+ },
43
+ }));
44
+
45
+ describe('RestoreCommand', () => {
46
+ const mockConfig = {
47
+ config: createMockConfig() as Config,
48
+ git: {},
49
+ } as CommandContext;
50
+
51
+ it('should return error if no checkpoint name is provided', async () => {
52
+ const command = new RestoreCommand();
53
+ const result = await command.execute(mockConfig, []);
54
+ expect(result.data).toEqual({
55
+ type: 'message',
56
+ messageType: 'error',
57
+ content: 'Please provide a checkpoint name to restore.',
58
+ });
59
+ });
60
+
61
+ it('should restore a checkpoint when a valid file is provided', async () => {
62
+ const command = new RestoreCommand();
63
+ const toolCallData = {
64
+ toolCall: {
65
+ name: 'test-tool',
66
+ args: {},
67
+ },
68
+ history: [],
69
+ clientHistory: [],
70
+ commitHash: '123',
71
+ };
72
+ mockFs.readFile.mockResolvedValue(JSON.stringify(toolCallData));
73
+ const restoreContent = {
74
+ type: 'message',
75
+ messageType: 'info',
76
+ content: 'Restored',
77
+ };
78
+ mockPerformRestore.mockReturnValue(
79
+ (async function* () {
80
+ yield restoreContent;
81
+ })(),
82
+ );
83
+ const result = await command.execute(mockConfig, ['checkpoint1.json']);
84
+ expect(result.data).toEqual([restoreContent]);
85
+ });
86
+
87
+ it('should show "file not found" error for a non-existent checkpoint', async () => {
88
+ const command = new RestoreCommand();
89
+ const error = new Error('File not found');
90
+ (error as NodeJS.ErrnoException).code = 'ENOENT';
91
+ mockFs.readFile.mockRejectedValue(error);
92
+ const result = await command.execute(mockConfig, ['checkpoint2.json']);
93
+ expect(result.data).toEqual({
94
+ type: 'message',
95
+ messageType: 'error',
96
+ content: 'File not found: checkpoint2.json',
97
+ });
98
+ });
99
+
100
+ it('should handle invalid JSON in checkpoint file', async () => {
101
+ const command = new RestoreCommand();
102
+ mockFs.readFile.mockResolvedValue('invalid json');
103
+ const result = await command.execute(mockConfig, ['checkpoint1.json']);
104
+ expect((result.data as { content: string }).content).toContain(
105
+ 'An unexpected error occurred during restore.',
106
+ );
107
+ });
108
+ });
109
+
110
+ describe('ListCheckpointsCommand', () => {
111
+ const mockConfig = {
112
+ config: createMockConfig() as Config,
113
+ } as CommandContext;
114
+
115
+ it('should list all available checkpoints', async () => {
116
+ const command = new ListCheckpointsCommand();
117
+ const checkpointInfo = [{ file: 'checkpoint1.json', description: 'Test' }];
118
+ mockFs.readdir.mockResolvedValue(['checkpoint1.json']);
119
+ mockFs.readFile.mockResolvedValue(
120
+ JSON.stringify({ toolCall: { name: 'Test', args: {} } }),
121
+ );
122
+ mockGetCheckpointInfoList.mockReturnValue(checkpointInfo);
123
+ const result = await command.execute(mockConfig);
124
+ expect((result.data as { content: string }).content).toEqual(
125
+ JSON.stringify(checkpointInfo),
126
+ );
127
+ });
128
+
129
+ it('should handle errors when listing checkpoints', async () => {
130
+ const command = new ListCheckpointsCommand();
131
+ mockFs.readdir.mockRejectedValue(new Error('Read error'));
132
+ const result = await command.execute(mockConfig);
133
+ expect((result.data as { content: string }).content).toContain(
134
+ 'An unexpected error occurred while listing checkpoints.',
135
+ );
136
+ });
137
+ });
packages/a2a-server/src/commands/restore.ts ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ getCheckpointInfoList,
9
+ getToolCallDataSchema,
10
+ isNodeError,
11
+ performRestore,
12
+ } from '@google/gemini-cli-core';
13
+ import * as fs from 'node:fs/promises';
14
+ import * as path from 'node:path';
15
+ import type {
16
+ Command,
17
+ CommandContext,
18
+ CommandExecutionResponse,
19
+ } from './types.js';
20
+
21
+ export class RestoreCommand implements Command {
22
+ readonly name = 'restore';
23
+ readonly description =
24
+ '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';
25
+ readonly topLevel = true;
26
+ readonly requiresWorkspace = true;
27
+ readonly subCommands = [new ListCheckpointsCommand()];
28
+
29
+ async execute(
30
+ context: CommandContext,
31
+ args: string[],
32
+ ): Promise<CommandExecutionResponse> {
33
+ const { config, git: gitService } = context;
34
+ const argsStr = args.join(' ');
35
+
36
+ try {
37
+ if (!argsStr) {
38
+ return {
39
+ name: this.name,
40
+ data: {
41
+ type: 'message',
42
+ messageType: 'error',
43
+ content: 'Please provide a checkpoint name to restore.',
44
+ },
45
+ };
46
+ }
47
+
48
+ const selectedFile = argsStr.endsWith('.json')
49
+ ? argsStr
50
+ : `${argsStr}.json`;
51
+
52
+ const checkpointDir = config.storage.getProjectTempCheckpointsDir();
53
+ const filePath = path.join(checkpointDir, selectedFile);
54
+
55
+ let data: string;
56
+ try {
57
+ data = await fs.readFile(filePath, 'utf-8');
58
+ } catch (error) {
59
+ if (isNodeError(error) && error.code === 'ENOENT') {
60
+ return {
61
+ name: this.name,
62
+ data: {
63
+ type: 'message',
64
+ messageType: 'error',
65
+ content: `File not found: ${selectedFile}`,
66
+ },
67
+ };
68
+ }
69
+ throw error;
70
+ }
71
+
72
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
73
+ const toolCallData = JSON.parse(data);
74
+ const ToolCallDataSchema = getToolCallDataSchema();
75
+ const parseResult = ToolCallDataSchema.safeParse(toolCallData);
76
+
77
+ if (!parseResult.success) {
78
+ return {
79
+ name: this.name,
80
+ data: {
81
+ type: 'message',
82
+ messageType: 'error',
83
+ content: 'Checkpoint file is invalid or corrupted.',
84
+ },
85
+ };
86
+ }
87
+
88
+ const restoreResultGenerator = performRestore(
89
+ parseResult.data,
90
+ gitService,
91
+ );
92
+ const restoreResult = [];
93
+ for await (const result of restoreResultGenerator) {
94
+ restoreResult.push(result);
95
+ }
96
+
97
+ return {
98
+ name: this.name,
99
+ data: restoreResult,
100
+ };
101
+ } catch {
102
+ return {
103
+ name: this.name,
104
+ data: {
105
+ type: 'message',
106
+ messageType: 'error',
107
+ content: 'An unexpected error occurred during restore.',
108
+ },
109
+ };
110
+ }
111
+ }
112
+ }
113
+
114
+ export class ListCheckpointsCommand implements Command {
115
+ readonly name = 'restore list';
116
+ readonly description = 'Lists all available checkpoints.';
117
+ readonly topLevel = false;
118
+
119
+ async execute(context: CommandContext): Promise<CommandExecutionResponse> {
120
+ const { config } = context;
121
+
122
+ try {
123
+ const checkpointDir = config.storage.getProjectTempCheckpointsDir();
124
+ await fs.mkdir(checkpointDir, { recursive: true });
125
+ const files = await fs.readdir(checkpointDir);
126
+ const jsonFiles = files.filter((file) => file.endsWith('.json'));
127
+
128
+ const checkpointFiles = new Map<string, string>();
129
+ for (const file of jsonFiles) {
130
+ const filePath = path.join(checkpointDir, file);
131
+ const data = await fs.readFile(filePath, 'utf-8');
132
+ checkpointFiles.set(file, data);
133
+ }
134
+
135
+ const checkpointInfoList = getCheckpointInfoList(checkpointFiles);
136
+
137
+ return {
138
+ name: this.name,
139
+ data: {
140
+ type: 'message',
141
+ messageType: 'info',
142
+ content: JSON.stringify(checkpointInfoList),
143
+ },
144
+ };
145
+ } catch {
146
+ return {
147
+ name: this.name,
148
+ data: {
149
+ type: 'message',
150
+ messageType: 'error',
151
+ content: 'An unexpected error occurred while listing checkpoints.',
152
+ },
153
+ };
154
+ }
155
+ }
156
+ }
packages/a2a-server/src/commands/types.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { ExecutionEventBus, AgentExecutor } from '@a2a-js/sdk/server';
8
+ import type { Config, GitService } from '@google/gemini-cli-core';
9
+
10
+ export interface CommandContext {
11
+ config: Config;
12
+ git?: GitService;
13
+ agentExecutor?: AgentExecutor;
14
+ eventBus?: ExecutionEventBus;
15
+ }
16
+
17
+ export interface CommandArgument {
18
+ readonly name: string;
19
+ readonly description: string;
20
+ readonly isRequired?: boolean;
21
+ }
22
+
23
+ export interface Command {
24
+ readonly name: string;
25
+ readonly description: string;
26
+ readonly arguments?: CommandArgument[];
27
+ readonly subCommands?: Command[];
28
+ readonly topLevel?: boolean;
29
+ readonly requiresWorkspace?: boolean;
30
+ readonly streaming?: boolean;
31
+
32
+ execute(
33
+ config: CommandContext,
34
+ args: string[],
35
+ ): Promise<CommandExecutionResponse>;
36
+ }
37
+
38
+ export interface CommandExecutionResponse {
39
+ readonly name: string;
40
+ readonly data: unknown;
41
+ }
packages/a2a-server/src/config/config.test.ts ADDED
@@ -0,0 +1,585 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+ import * as path from 'node:path';
9
+ import { loadConfig } from './config.js';
10
+ import type { Settings } from './settings.js';
11
+ import {
12
+ type ExtensionLoader,
13
+ getCodeAssistServer,
14
+ Config,
15
+ ExperimentFlags,
16
+ fetchAdminControlsOnce,
17
+ type FetchAdminControlsResponse,
18
+ AuthType,
19
+ isHeadlessMode,
20
+ FatalAuthenticationError,
21
+ PolicyDecision,
22
+ ApprovalMode,
23
+ PRIORITY_YOLO_ALLOW_ALL,
24
+ createPolicyEngineConfig,
25
+ } from '@google/gemini-cli-core';
26
+ import type { AgentSettings } from '../types.js';
27
+
28
+ // Mock dependencies
29
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
30
+ const actual =
31
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
32
+ return {
33
+ ...actual,
34
+ PRIORITY_YOLO_ALLOW_ALL: 998,
35
+ Config: vi.fn().mockImplementation((params) => {
36
+ const mockConfig = {
37
+ ...params,
38
+ initialize: vi.fn(),
39
+ waitForMcpInit: vi.fn(),
40
+ refreshAuth: vi.fn(),
41
+ getExperiments: vi.fn().mockReturnValue({
42
+ flags: {
43
+ [actual.ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
44
+ boolValue: false,
45
+ },
46
+ },
47
+ }),
48
+ getRemoteAdminSettings: vi.fn(),
49
+ setRemoteAdminSettings: vi.fn(),
50
+ };
51
+ return mockConfig;
52
+ }),
53
+ startupProfiler: {
54
+ flush: vi.fn(),
55
+ },
56
+ isHeadlessMode: vi.fn().mockReturnValue(false),
57
+ getCodeAssistServer: vi.fn(),
58
+ fetchAdminControlsOnce: vi.fn(),
59
+ createPolicyEngineConfig: vi
60
+ .fn()
61
+ .mockImplementation(
62
+ (_settings, mode, _defaultPoliciesDir, _interactive) => ({
63
+ rules:
64
+ mode === actual.ApprovalMode.YOLO
65
+ ? [
66
+ {
67
+ toolName: '*',
68
+ decision: actual.PolicyDecision.ALLOW,
69
+ priority: actual.PRIORITY_YOLO_ALLOW_ALL,
70
+ modes: [actual.ApprovalMode.YOLO],
71
+ allowRedirection: true,
72
+ },
73
+ ]
74
+ : [
75
+ {
76
+ toolName: 'read_file',
77
+ decision: actual.PolicyDecision.ALLOW,
78
+ priority: 1.05,
79
+ source: 'Default: read-only.toml',
80
+ },
81
+ ],
82
+ checkers: [],
83
+ }),
84
+ ),
85
+ coreEvents: {
86
+ emitAdminSettingsChanged: vi.fn(),
87
+ },
88
+ };
89
+ });
90
+
91
+ vi.mock('../utils/logger.js', () => ({
92
+ logger: {
93
+ info: vi.fn(),
94
+ warn: vi.fn(),
95
+ error: vi.fn(),
96
+ },
97
+ }));
98
+
99
+ describe('loadConfig', () => {
100
+ const mockSettings = {} as Settings;
101
+ const mockExtensionLoader = {} as ExtensionLoader;
102
+ const taskId = 'test-task-id';
103
+
104
+ beforeEach(() => {
105
+ vi.clearAllMocks();
106
+ vi.stubEnv('GEMINI_API_KEY', 'test-key');
107
+ });
108
+
109
+ afterEach(() => {
110
+ vi.unstubAllEnvs();
111
+ });
112
+
113
+ describe('admin settings overrides', () => {
114
+ it('should not fetch admin controls if experiment is disabled', async () => {
115
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
116
+ expect(fetchAdminControlsOnce).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it('should pass clientName as a2a-server to Config', async () => {
120
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
121
+ expect(Config).toHaveBeenCalledWith(
122
+ expect.objectContaining({
123
+ clientName: 'a2a-server',
124
+ }),
125
+ );
126
+ });
127
+
128
+ describe('when admin controls experiment is enabled', () => {
129
+ beforeEach(() => {
130
+ // We need to cast to any here to modify the mock implementation
131
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
132
+ (Config as any).mockImplementation((params: unknown) => {
133
+ const mockConfig = {
134
+ ...(params as object),
135
+ initialize: vi.fn(),
136
+ waitForMcpInit: vi.fn(),
137
+ refreshAuth: vi.fn(),
138
+ getExperiments: vi.fn().mockReturnValue({
139
+ flags: {
140
+ [ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
141
+ boolValue: true,
142
+ },
143
+ },
144
+ }),
145
+ getRemoteAdminSettings: vi.fn().mockReturnValue({}),
146
+ setRemoteAdminSettings: vi.fn(),
147
+ };
148
+ return mockConfig;
149
+ });
150
+ });
151
+
152
+ it('should fetch admin controls and apply them', async () => {
153
+ const mockAdminSettings: FetchAdminControlsResponse = {
154
+ mcpSetting: {
155
+ mcpEnabled: false,
156
+ },
157
+ cliFeatureSetting: {
158
+ extensionsSetting: {
159
+ extensionsEnabled: false,
160
+ },
161
+ },
162
+ strictModeDisabled: false,
163
+ };
164
+ vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
165
+
166
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
167
+
168
+ expect(Config).toHaveBeenLastCalledWith(
169
+ expect.objectContaining({
170
+ disableYoloMode: !mockAdminSettings.strictModeDisabled,
171
+ mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
172
+ extensionsEnabled:
173
+ mockAdminSettings.cliFeatureSetting?.extensionsSetting
174
+ ?.extensionsEnabled,
175
+ }),
176
+ );
177
+ });
178
+
179
+ it('should treat unset admin settings as false when admin settings are passed', async () => {
180
+ const mockAdminSettings: FetchAdminControlsResponse = {
181
+ mcpSetting: {
182
+ mcpEnabled: true,
183
+ },
184
+ };
185
+ vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
186
+
187
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
188
+
189
+ expect(Config).toHaveBeenLastCalledWith(
190
+ expect.objectContaining({
191
+ disableYoloMode: !false,
192
+ mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
193
+ extensionsEnabled: undefined,
194
+ }),
195
+ );
196
+ });
197
+
198
+ it('should not pass default unset admin settings when no admin settings are present', async () => {
199
+ const mockAdminSettings: FetchAdminControlsResponse = {};
200
+ vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
201
+
202
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
203
+
204
+ expect(Config).toHaveBeenLastCalledWith(expect.objectContaining({}));
205
+ });
206
+
207
+ it('should fetch admin controls using the code assist server when available', async () => {
208
+ const mockAdminSettings: FetchAdminControlsResponse = {
209
+ mcpSetting: {
210
+ mcpEnabled: true,
211
+ },
212
+ strictModeDisabled: true,
213
+ };
214
+ const mockCodeAssistServer = { projectId: 'test-project' };
215
+ vi.mocked(getCodeAssistServer).mockReturnValue(
216
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
217
+ mockCodeAssistServer as any,
218
+ );
219
+ vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
220
+
221
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
222
+
223
+ expect(fetchAdminControlsOnce).toHaveBeenCalledWith(
224
+ mockCodeAssistServer,
225
+ true,
226
+ );
227
+ expect(Config).toHaveBeenLastCalledWith(
228
+ expect.objectContaining({
229
+ disableYoloMode: !mockAdminSettings.strictModeDisabled,
230
+ mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
231
+ extensionsEnabled: undefined,
232
+ }),
233
+ );
234
+ });
235
+ });
236
+ });
237
+
238
+ it('should set customIgnoreFilePaths when CUSTOM_IGNORE_FILE_PATHS env var is present', async () => {
239
+ const testPath = '/tmp/ignore';
240
+ vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath);
241
+ const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
242
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
243
+ expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
244
+ testPath,
245
+ ]);
246
+ });
247
+
248
+ it('should set customIgnoreFilePaths when settings.fileFiltering.customIgnoreFilePaths is present', async () => {
249
+ const testPath = '/settings/ignore';
250
+ const settings: Settings = {
251
+ fileFiltering: {
252
+ customIgnoreFilePaths: [testPath],
253
+ },
254
+ };
255
+ const config = await loadConfig(settings, mockExtensionLoader, taskId);
256
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
257
+ expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
258
+ testPath,
259
+ ]);
260
+ });
261
+
262
+ it('should merge customIgnoreFilePaths from settings and env var', async () => {
263
+ const envPath = '/env/ignore';
264
+ const settingsPath = '/settings/ignore';
265
+ vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', envPath);
266
+ const settings: Settings = {
267
+ fileFiltering: {
268
+ customIgnoreFilePaths: [settingsPath],
269
+ },
270
+ };
271
+ const config = await loadConfig(settings, mockExtensionLoader, taskId);
272
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
273
+ expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
274
+ settingsPath,
275
+ envPath,
276
+ ]);
277
+ });
278
+
279
+ it('should split CUSTOM_IGNORE_FILE_PATHS using system delimiter', async () => {
280
+ const paths = ['/path/one', '/path/two'];
281
+ vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', paths.join(path.delimiter));
282
+ const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
283
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
284
+ expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual(paths);
285
+ });
286
+
287
+ it('should have empty customIgnoreFilePaths when both are missing', async () => {
288
+ const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
289
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
290
+ expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([]);
291
+ });
292
+
293
+ describe('policy engine configuration', () => {
294
+ it('should map tool settings into policySettings', async () => {
295
+ const settings: Settings = {
296
+ tools: {
297
+ allowed: ['v2-allowed'],
298
+ exclude: ['v2-exclude'],
299
+ core: ['v2-core'],
300
+ },
301
+ mcpServers: {
302
+ test: { command: 'test', args: [] },
303
+ },
304
+ policyPaths: ['/path/to/policy'],
305
+ adminPolicyPaths: ['/path/to/admin/policy'],
306
+ };
307
+
308
+ await loadConfig(settings, mockExtensionLoader, taskId, true);
309
+
310
+ expect(createPolicyEngineConfig).toHaveBeenCalledWith(
311
+ expect.objectContaining({
312
+ tools: {
313
+ core: ['v2-core'],
314
+ exclude: ['v2-exclude'],
315
+ allowed: ['v2-allowed'],
316
+ },
317
+ mcpServers: settings.mcpServers,
318
+ policyPaths: settings.policyPaths,
319
+ adminPolicyPaths: settings.adminPolicyPaths,
320
+ }),
321
+ ApprovalMode.DEFAULT,
322
+ undefined,
323
+ true,
324
+ );
325
+ });
326
+ });
327
+
328
+ describe('tool configuration', () => {
329
+ it('should pass V2 tools.allowed to Config properly', async () => {
330
+ const settings: Settings = {
331
+ tools: {
332
+ allowed: ['shell', 'fetch'],
333
+ },
334
+ };
335
+ await loadConfig(settings, mockExtensionLoader, taskId, true);
336
+ expect(Config).toHaveBeenCalledWith(
337
+ expect.objectContaining({
338
+ allowedTools: ['shell', 'fetch'],
339
+ }),
340
+ );
341
+ });
342
+
343
+ it('should pass enableAgents to Config constructor', async () => {
344
+ const settings: Settings = {
345
+ experimental: {
346
+ enableAgents: false,
347
+ },
348
+ };
349
+ await loadConfig(settings, mockExtensionLoader, taskId);
350
+ expect(Config).toHaveBeenCalledWith(
351
+ expect.objectContaining({
352
+ enableAgents: false,
353
+ }),
354
+ );
355
+ });
356
+
357
+ it('should default enableAgents to true when not provided', async () => {
358
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
359
+ expect(Config).toHaveBeenCalledWith(
360
+ expect.objectContaining({
361
+ enableAgents: true,
362
+ }),
363
+ );
364
+ });
365
+
366
+ describe('interactivity', () => {
367
+ it('should always set interactive true', async () => {
368
+ vi.mocked(isHeadlessMode).mockReturnValue(true);
369
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
370
+ expect(Config).toHaveBeenCalledWith(
371
+ expect.objectContaining({
372
+ interactive: true,
373
+ }),
374
+ );
375
+
376
+ vi.mocked(isHeadlessMode).mockReturnValue(false);
377
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
378
+ expect(Config).toHaveBeenCalledWith(
379
+ expect.objectContaining({
380
+ interactive: true,
381
+ }),
382
+ );
383
+ });
384
+
385
+ it('should set enableInteractiveShell based on headless mode', async () => {
386
+ vi.mocked(isHeadlessMode).mockReturnValue(false);
387
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
388
+ expect(Config).toHaveBeenCalledWith(
389
+ expect.objectContaining({
390
+ enableInteractiveShell: true,
391
+ }),
392
+ );
393
+
394
+ vi.mocked(isHeadlessMode).mockReturnValue(true);
395
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
396
+ expect(Config).toHaveBeenCalledWith(
397
+ expect.objectContaining({
398
+ enableInteractiveShell: false,
399
+ }),
400
+ );
401
+ });
402
+ });
403
+
404
+ describe('YOLO mode', () => {
405
+ it('should enable YOLO mode and add policy rule when GEMINI_YOLO_MODE is true', async () => {
406
+ vi.stubEnv('GEMINI_YOLO_MODE', 'true');
407
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
408
+ expect(Config).toHaveBeenCalledWith(
409
+ expect.objectContaining({
410
+ approvalMode: 'yolo',
411
+ policyEngineConfig: expect.objectContaining({
412
+ rules: expect.arrayContaining([
413
+ expect.objectContaining({
414
+ decision: PolicyDecision.ALLOW,
415
+ priority: PRIORITY_YOLO_ALLOW_ALL,
416
+ modes: ['yolo'],
417
+ allowRedirection: true,
418
+ }),
419
+ ]),
420
+ }),
421
+ }),
422
+ );
423
+ });
424
+
425
+ it('should use default approval mode and load default rules when GEMINI_YOLO_MODE is not true', async () => {
426
+ vi.stubEnv('GEMINI_YOLO_MODE', 'false');
427
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
428
+ expect(Config).toHaveBeenCalledWith(
429
+ expect.objectContaining({
430
+ approvalMode: 'default',
431
+ policyEngineConfig: expect.objectContaining({
432
+ rules: expect.arrayContaining([
433
+ expect.objectContaining({
434
+ toolName: 'read_file',
435
+ decision: PolicyDecision.ALLOW,
436
+ }),
437
+ ]),
438
+ }),
439
+ }),
440
+ );
441
+ });
442
+ });
443
+
444
+ describe('authentication logic', () => {
445
+ const setupConfigMock = (refreshAuthMock: ReturnType<typeof vi.fn>) => {
446
+ vi.mocked(Config).mockImplementation(
447
+ (params: unknown) =>
448
+ ({
449
+ ...(params as object),
450
+ initialize: vi.fn(),
451
+ waitForMcpInit: vi.fn(),
452
+ refreshAuth: refreshAuthMock,
453
+ getExperiments: vi.fn().mockReturnValue({ flags: {} }),
454
+ getRemoteAdminSettings: vi.fn(),
455
+ setRemoteAdminSettings: vi.fn(),
456
+ }) as unknown as Config,
457
+ );
458
+ };
459
+
460
+ beforeEach(() => {
461
+ vi.stubEnv('USE_CCPA', 'true');
462
+ vi.stubEnv('GEMINI_API_KEY', '');
463
+ });
464
+
465
+ afterEach(() => {
466
+ vi.unstubAllEnvs();
467
+ });
468
+
469
+ it('should attempt COMPUTE_ADC by default and bypass LOGIN_WITH_GOOGLE if successful', async () => {
470
+ const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
471
+ setupConfigMock(refreshAuthMock);
472
+
473
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
474
+
475
+ expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
476
+ expect(refreshAuthMock).not.toHaveBeenCalledWith(
477
+ AuthType.LOGIN_WITH_GOOGLE,
478
+ );
479
+ });
480
+
481
+ it('should fallback to LOGIN_WITH_GOOGLE if COMPUTE_ADC fails and interactive mode is available', async () => {
482
+ vi.mocked(isHeadlessMode).mockReturnValue(false);
483
+ const refreshAuthMock = vi.fn().mockImplementation((authType) => {
484
+ if (authType === AuthType.COMPUTE_ADC) {
485
+ return Promise.reject(new Error('ADC failed'));
486
+ }
487
+ return Promise.resolve();
488
+ });
489
+ setupConfigMock(refreshAuthMock);
490
+
491
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
492
+
493
+ expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
494
+ expect(refreshAuthMock).toHaveBeenCalledWith(
495
+ AuthType.LOGIN_WITH_GOOGLE,
496
+ );
497
+ });
498
+
499
+ it('should throw FatalAuthenticationError in headless mode if COMPUTE_ADC fails', async () => {
500
+ vi.mocked(isHeadlessMode).mockReturnValue(true);
501
+
502
+ const refreshAuthMock = vi.fn().mockImplementation((authType) => {
503
+ if (authType === AuthType.COMPUTE_ADC) {
504
+ return Promise.reject(new Error('ADC not found'));
505
+ }
506
+ return Promise.resolve();
507
+ });
508
+ setupConfigMock(refreshAuthMock);
509
+
510
+ await expect(
511
+ loadConfig(mockSettings, mockExtensionLoader, taskId),
512
+ ).rejects.toThrow(
513
+ 'COMPUTE_ADC failed: ADC not found. (LOGIN_WITH_GOOGLE fallback skipped due to headless mode. Run in an interactive terminal to use OAuth.)',
514
+ );
515
+
516
+ expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
517
+ expect(refreshAuthMock).not.toHaveBeenCalledWith(
518
+ AuthType.LOGIN_WITH_GOOGLE,
519
+ );
520
+ });
521
+
522
+ it('should include both original and fallback error when LOGIN_WITH_GOOGLE fallback fails', async () => {
523
+ vi.mocked(isHeadlessMode).mockReturnValue(false);
524
+
525
+ const refreshAuthMock = vi.fn().mockImplementation((authType) => {
526
+ if (authType === AuthType.COMPUTE_ADC) {
527
+ throw new Error('ADC failed');
528
+ }
529
+ if (authType === AuthType.LOGIN_WITH_GOOGLE) {
530
+ throw new FatalAuthenticationError('OAuth failed');
531
+ }
532
+ return Promise.resolve();
533
+ });
534
+ setupConfigMock(refreshAuthMock);
535
+
536
+ await expect(
537
+ loadConfig(mockSettings, mockExtensionLoader, taskId),
538
+ ).rejects.toThrow(
539
+ 'OAuth failed. The initial COMPUTE_ADC attempt also failed: ADC failed',
540
+ );
541
+ });
542
+ });
543
+ });
544
+ });
545
+
546
+ describe('setIsTrusted', () => {
547
+ beforeEach(() => {
548
+ vi.resetModules();
549
+ // Ensure GEMINI_CLI_TRUST_WORKSPACE is not set by default in tests to prevent leakage
550
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', '');
551
+ });
552
+
553
+ afterEach(() => {
554
+ vi.unstubAllEnvs();
555
+ });
556
+
557
+ it('should return agentSettings.isTrusted if defined, ignoring env vars', async () => {
558
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'false');
559
+ const { setIsTrusted } = await import('./config.js');
560
+ expect(setIsTrusted({ isTrusted: true } as AgentSettings)).toBe(true);
561
+
562
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
563
+ expect(setIsTrusted({ isTrusted: false } as AgentSettings)).toBe(false);
564
+ });
565
+
566
+ it('should return true when GEMINI_CLI_TRUST_WORKSPACE env var is true and agentSettings.isTrusted is undefined', async () => {
567
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
568
+ const { setIsTrusted } = await import('./config.js');
569
+ expect(setIsTrusted(undefined)).toBe(true);
570
+ expect(setIsTrusted({} as AgentSettings)).toBe(true);
571
+ });
572
+
573
+ it('should return false when GEMINI_CLI_TRUST_WORKSPACE env var is false and agentSettings.isTrusted is undefined', async () => {
574
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'false');
575
+ const { setIsTrusted } = await import('./config.js');
576
+ expect(setIsTrusted(undefined)).toBe(false);
577
+ expect(setIsTrusted({} as AgentSettings)).toBe(false);
578
+ });
579
+
580
+ it('should fallback to false if agentSettings.isTrusted and env var are undefined and no workspaceRoot is provided', async () => {
581
+ const { setIsTrusted } = await import('./config.js');
582
+ expect(setIsTrusted(undefined)).toBe(false);
583
+ expect(setIsTrusted({} as AgentSettings)).toBe(false);
584
+ });
585
+ });
packages/a2a-server/src/config/config.ts ADDED
@@ -0,0 +1,708 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import * as dotenv from 'dotenv';
10
+ import { AsyncLocalStorage } from 'node:async_hooks';
11
+
12
+ import {
13
+ AuthType,
14
+ Config,
15
+ ApprovalMode,
16
+ GEMINI_DIR,
17
+ DEFAULT_GEMINI_EMBEDDING_MODEL,
18
+ startupProfiler,
19
+ PREVIEW_GEMINI_MODEL,
20
+ homedir,
21
+ tmpdir,
22
+ GitService,
23
+ fetchAdminControlsOnce,
24
+ getCodeAssistServer,
25
+ ExperimentFlags,
26
+ isHeadlessMode,
27
+ FatalAuthenticationError,
28
+ createPolicyEngineConfig,
29
+ type PolicySettings,
30
+ type TelemetryTarget,
31
+ type ConfigParameters,
32
+ type ExtensionLoader,
33
+ resolveToRealPath,
34
+ checkPathTrust,
35
+ } from '@google/gemini-cli-core';
36
+
37
+ import { logger } from '../utils/logger.js';
38
+ import { type Settings, loadSettings } from './settings.js';
39
+ import { type AgentSettings, CoderAgentEvent } from '../types.js';
40
+
41
+ export const envStorage = new AsyncLocalStorage<TaskEnv>();
42
+
43
+ const deletedKeysSymbol = Symbol('deletedKeys');
44
+ export const cwdSymbol = Symbol('cwd');
45
+
46
+ export interface TaskEnv extends Record<string, string | undefined> {
47
+ [deletedKeysSymbol]?: Set<string>;
48
+ [cwdSymbol]?: string;
49
+ }
50
+
51
+ // Set up a Proxy on process.env to intercept reads and writes, isolating environment variables per task
52
+ const originalEnv = process.env;
53
+ const envProxy = new Proxy(originalEnv, {
54
+ get(target, prop) {
55
+ if (typeof prop === 'string') {
56
+ const taskEnv = envStorage.getStore();
57
+ if (taskEnv) {
58
+ const deleted = taskEnv[deletedKeysSymbol];
59
+ if (deleted?.has(prop)) {
60
+ return undefined;
61
+ }
62
+ if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
63
+ return taskEnv[prop];
64
+ }
65
+ }
66
+ return target[prop];
67
+ }
68
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
69
+ return target[prop as any];
70
+ },
71
+ has(target, prop) {
72
+ if (typeof prop === 'string') {
73
+ const taskEnv = envStorage.getStore();
74
+ if (taskEnv) {
75
+ const deleted = taskEnv[deletedKeysSymbol];
76
+ if (deleted?.has(prop)) {
77
+ return false;
78
+ }
79
+ if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
80
+ return true;
81
+ }
82
+ }
83
+ return prop in target;
84
+ }
85
+ return prop in target;
86
+ },
87
+ set(target, prop, value) {
88
+ if (typeof prop === 'string') {
89
+ if (
90
+ prop === '__proto__' ||
91
+ prop === 'constructor' ||
92
+ prop === 'prototype'
93
+ ) {
94
+ return false;
95
+ }
96
+ const taskEnv = envStorage.getStore();
97
+ if (taskEnv) {
98
+ taskEnv[deletedKeysSymbol]?.delete(prop);
99
+ taskEnv[prop] = String(value);
100
+ return true;
101
+ }
102
+ target[prop] = String(value);
103
+ return true;
104
+ }
105
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
106
+ target[prop as any] = value;
107
+ return true;
108
+ },
109
+ deleteProperty(target, prop) {
110
+ if (typeof prop === 'string') {
111
+ if (
112
+ prop === '__proto__' ||
113
+ prop === 'constructor' ||
114
+ prop === 'prototype'
115
+ ) {
116
+ return false;
117
+ }
118
+ const taskEnv = envStorage.getStore();
119
+ if (taskEnv) {
120
+ delete taskEnv[prop];
121
+ (taskEnv[deletedKeysSymbol] ??= new Set()).add(prop);
122
+ return true;
123
+ }
124
+ delete target[prop];
125
+ return true;
126
+ }
127
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
128
+ return delete target[prop as any];
129
+ },
130
+ ownKeys(target) {
131
+ const taskEnv = envStorage.getStore();
132
+ if (taskEnv) {
133
+ const keys = new Set<string | symbol>([
134
+ ...Object.getOwnPropertyNames(target),
135
+ ...Object.getOwnPropertySymbols(target),
136
+ ...Object.keys(taskEnv),
137
+ ]);
138
+ taskEnv[deletedKeysSymbol]?.forEach((key) => {
139
+ keys.delete(key);
140
+ });
141
+ return Array.from(keys);
142
+ }
143
+ return [
144
+ ...Object.getOwnPropertyNames(target),
145
+ ...Object.getOwnPropertySymbols(target),
146
+ ];
147
+ },
148
+ getOwnPropertyDescriptor(target, prop) {
149
+ const taskEnv = envStorage.getStore();
150
+ if (taskEnv && typeof prop === 'string') {
151
+ const deleted = taskEnv[deletedKeysSymbol];
152
+ if (deleted?.has(prop)) {
153
+ return undefined;
154
+ }
155
+ if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
156
+ return {
157
+ value: taskEnv[prop],
158
+ writable: true,
159
+ enumerable: true,
160
+ configurable: true,
161
+ };
162
+ }
163
+ }
164
+ return Object.getOwnPropertyDescriptor(target, prop);
165
+ },
166
+ defineProperty(target, prop, descriptor) {
167
+ if (typeof prop === 'string') {
168
+ if (
169
+ prop === '__proto__' ||
170
+ prop === 'constructor' ||
171
+ prop === 'prototype'
172
+ ) {
173
+ return false;
174
+ }
175
+ const taskEnv = envStorage.getStore();
176
+ if (taskEnv) {
177
+ taskEnv[deletedKeysSymbol]?.delete(prop);
178
+ taskEnv[prop] =
179
+ descriptor.value !== undefined ? String(descriptor.value) : undefined;
180
+ return true;
181
+ }
182
+ }
183
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
184
+ Object.defineProperty(target, prop as any, descriptor);
185
+ return true;
186
+ },
187
+ });
188
+
189
+ Object.defineProperty(process, 'env', {
190
+ value: envProxy,
191
+ writable: false,
192
+ configurable: true,
193
+ });
194
+
195
+ // NOTE: Monkey-patching process.cwd and process.chdir via AsyncLocalStorage is a robust way
196
+ // to simulate workspace isolation in a concurrent server. However, please be aware of a critical
197
+ // limitation: Node.js native C++ APIs (such as fs.readFileSync, fs.writeFile, etc.) and child
198
+ // process spawning APIs (like child_process.spawn) resolve relative paths using the OS-level
199
+ // working directory of the process, NOT the JS-level process.cwd() function.
200
+ // To prevent cross-task interference, all file paths in the core package must be resolved to
201
+ // absolute paths using path.resolve/path.join relative to config.getTargetDir() or config.getCwd()
202
+ // before being passed to native APIs.
203
+ const originalCwd = process.cwd;
204
+ process.cwd = function () {
205
+ const taskEnv = envStorage.getStore();
206
+ if (taskEnv && taskEnv[cwdSymbol]) {
207
+ return taskEnv[cwdSymbol];
208
+ }
209
+ return originalCwd.call(process);
210
+ };
211
+
212
+ const originalChdir = process.chdir;
213
+ process.chdir = function (directory: string) {
214
+ const taskEnv = envStorage.getStore();
215
+ if (taskEnv) {
216
+ const resolved = path.resolve(process.cwd(), directory);
217
+ try {
218
+ const stats = fs.statSync(resolved);
219
+ if (!stats.isDirectory()) {
220
+ const err = new Error(
221
+ "ENOTDIR: not a directory, chdir '" + resolved + "'",
222
+ );
223
+ (err as NodeJS.ErrnoException).code = 'ENOTDIR';
224
+ throw err;
225
+ }
226
+ } catch (err: unknown) {
227
+ if (
228
+ err &&
229
+ typeof err === 'object' &&
230
+ 'code' in err &&
231
+ err.code === 'ENOENT'
232
+ ) {
233
+ const chdirErr = new Error(
234
+ "ENOENT: no such file or directory, chdir '" + resolved + "'",
235
+ );
236
+ (chdirErr as NodeJS.ErrnoException).code = 'ENOENT';
237
+ throw chdirErr;
238
+ }
239
+ throw err;
240
+ }
241
+ taskEnv[cwdSymbol] = resolved;
242
+ return;
243
+ }
244
+ return originalChdir.call(process, directory);
245
+ };
246
+
247
+ export function getEnv(key: string): string | undefined {
248
+ return process.env[key];
249
+ }
250
+
251
+ export async function loadConfig(
252
+ settings: Settings,
253
+ extensionLoader: ExtensionLoader,
254
+ taskId: string,
255
+ trusted: boolean = false,
256
+ workspaceDir: string = process.cwd(),
257
+ ): Promise<Config> {
258
+ const workspaceEnv = await loadEnvironment(trusted, workspaceDir);
259
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
260
+ const envVars: Record<string, string> = { ...process.env } as Record<
261
+ string,
262
+ string
263
+ >;
264
+ Object.assign(envVars, workspaceEnv);
265
+
266
+ const getEnvLocal = (key: string) => envVars[key];
267
+
268
+ const folderTrust =
269
+ settings.folderTrust === true ||
270
+ getEnvLocal('GEMINI_FOLDER_TRUST') === 'true';
271
+
272
+ let checkpointing = getEnvLocal('CHECKPOINTING')
273
+ ? getEnvLocal('CHECKPOINTING') === 'true'
274
+ : settings.checkpointing?.enabled;
275
+
276
+ if (checkpointing) {
277
+ if (!(await GitService.verifyGitAvailability())) {
278
+ logger.warn(
279
+ '[Config] Checkpointing is enabled but git is not installed. Disabling checkpointing.',
280
+ );
281
+ checkpointing = false;
282
+ }
283
+ }
284
+
285
+ const approvalMode =
286
+ getEnvLocal('GEMINI_YOLO_MODE') === 'true'
287
+ ? ApprovalMode.YOLO
288
+ : ApprovalMode.DEFAULT;
289
+
290
+ if (!trusted) {
291
+ if (settings.mcpServers) {
292
+ logger.warn(
293
+ '[Configuration] Untrusted workspace detected. Stripping repository mcpServers definitions to prevent unintended command execution.',
294
+ );
295
+ }
296
+ if (settings.policyPaths) {
297
+ logger.warn(
298
+ '[Configuration] Untrusted workspace detected. Stripping repository policyPaths definitions to prevent unintended policy override.',
299
+ );
300
+ }
301
+ if (settings.adminPolicyPaths) {
302
+ logger.warn(
303
+ '[Configuration] Untrusted workspace detected. Stripping repository adminPolicyPaths definitions to prevent unintended admin policy override.',
304
+ );
305
+ }
306
+ if (settings.tools) {
307
+ logger.warn(
308
+ '[Configuration] Untrusted workspace detected. Stripping repository tools definitions to prevent unintended tool enablement.',
309
+ );
310
+ }
311
+ if (settings.telemetry) {
312
+ logger.warn(
313
+ '[Configuration] Untrusted workspace detected. Stripping repository telemetry definitions to prevent unintended data routing.',
314
+ );
315
+ }
316
+ settings = {
317
+ ...settings,
318
+ mcpServers: undefined,
319
+ policyPaths: undefined,
320
+ adminPolicyPaths: undefined,
321
+ tools: undefined,
322
+ telemetry: undefined,
323
+ };
324
+ }
325
+ const safeMcpServers = settings.mcpServers;
326
+
327
+ const policySettings: PolicySettings = {
328
+ mcpServers: safeMcpServers,
329
+ tools: {
330
+ core: settings.tools?.core,
331
+ exclude: settings.tools?.exclude,
332
+ allowed: settings.tools?.allowed,
333
+ },
334
+ policyPaths: settings.policyPaths,
335
+ adminPolicyPaths: settings.adminPolicyPaths,
336
+ };
337
+
338
+ const policyEngineConfig = await createPolicyEngineConfig(
339
+ policySettings,
340
+ approvalMode,
341
+ undefined,
342
+ true,
343
+ );
344
+
345
+ const configParams: ConfigParameters = {
346
+ sessionId: taskId,
347
+ clientName: 'a2a-server',
348
+ model: PREVIEW_GEMINI_MODEL,
349
+ embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
350
+ sandbox: undefined, // Sandbox might not be relevant for a server-side agent
351
+ targetDir: workspaceDir, // Or a specific directory the agent operates on
352
+ debugMode: getEnvLocal('DEBUG') === 'true' || false,
353
+ question: '', // Not used in server mode directly like CLI
354
+ env: envVars,
355
+
356
+ coreTools: settings.tools?.core || undefined,
357
+ excludeTools: settings.tools?.exclude || undefined,
358
+ allowedTools: settings.tools?.allowed || undefined,
359
+ showMemoryUsage: settings.showMemoryUsage || false,
360
+ approvalMode,
361
+ policyEngineConfig,
362
+ mcpServers: safeMcpServers,
363
+ cwd: workspaceDir,
364
+ telemetry: {
365
+ enabled: settings.telemetry?.enabled,
366
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
367
+ target: settings.telemetry?.target as TelemetryTarget,
368
+ otlpEndpoint:
369
+ getEnvLocal('OTEL_EXPORTER_OTLP_ENDPOINT') ??
370
+ settings.telemetry?.otlpEndpoint,
371
+ logPrompts: settings.telemetry?.logPrompts,
372
+ },
373
+ // Git-aware file filtering settings
374
+ fileFiltering: {
375
+ respectGitIgnore: settings.fileFiltering?.respectGitIgnore,
376
+ respectGeminiIgnore: settings.fileFiltering?.respectGeminiIgnore,
377
+ enableRecursiveFileSearch:
378
+ settings.fileFiltering?.enableRecursiveFileSearch,
379
+ customIgnoreFilePaths: [
380
+ ...(settings.fileFiltering?.customIgnoreFilePaths || []),
381
+ ...(getEnvLocal('CUSTOM_IGNORE_FILE_PATHS')
382
+ ? getEnvLocal('CUSTOM_IGNORE_FILE_PATHS').split(path.delimiter)
383
+ : []),
384
+ ],
385
+ },
386
+ ideMode: false,
387
+ folderTrust,
388
+ trustedFolder: trusted,
389
+ extensionLoader,
390
+ checkpointing,
391
+ interactive: true,
392
+ enableInteractiveShell: !isHeadlessMode(),
393
+ ptyInfo: 'auto',
394
+ enableAgents: settings.experimental?.enableAgents ?? true,
395
+ };
396
+
397
+ // Set an initial config to use to get a code assist server.
398
+ // This is needed to fetch admin controls.
399
+ const initialConfig = new Config({
400
+ ...configParams,
401
+ });
402
+
403
+ const codeAssistServer = getCodeAssistServer(initialConfig);
404
+
405
+ const adminControlsEnabled =
406
+ initialConfig.getExperiments()?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]
407
+ ?.boolValue ?? false;
408
+
409
+ // Initialize final config parameters to the previous parameters.
410
+ // If no admin controls are needed, these will be used as-is for the final
411
+ // config.
412
+ const finalConfigParams = { ...configParams };
413
+ if (adminControlsEnabled) {
414
+ const adminSettings = await fetchAdminControlsOnce(
415
+ codeAssistServer,
416
+ adminControlsEnabled,
417
+ );
418
+
419
+ // Admin settings are able to be undefined if unset, but if any are present,
420
+ // we should initialize them all.
421
+ // If any are present, undefined settings should be treated as if they were
422
+ // set to false.
423
+ // If NONE are present, disregard admin settings entirely, and pass the
424
+ // final config as is.
425
+ if (Object.keys(adminSettings).length !== 0) {
426
+ finalConfigParams.disableYoloMode = !adminSettings.strictModeDisabled;
427
+ finalConfigParams.mcpEnabled = adminSettings.mcpSetting?.mcpEnabled;
428
+ finalConfigParams.extensionsEnabled =
429
+ adminSettings.cliFeatureSetting?.extensionsSetting?.extensionsEnabled;
430
+ }
431
+ }
432
+
433
+ const config = new Config(finalConfigParams);
434
+
435
+ // Needed to initialize ToolRegistry, and git checkpointing if enabled
436
+ await config.initialize();
437
+
438
+ await config.waitForMcpInit();
439
+ startupProfiler.flush(config);
440
+
441
+ await refreshAuthentication(config, 'Config', envVars);
442
+
443
+ return config;
444
+ }
445
+
446
+ export function setIsTrusted(
447
+ agentSettings: AgentSettings | undefined,
448
+ workspaceRoot?: string,
449
+ ): boolean {
450
+ if (agentSettings?.isTrusted !== undefined) {
451
+ return agentSettings.isTrusted;
452
+ }
453
+ const cliTrustEnv = getEnv('GEMINI_CLI_TRUST_WORKSPACE');
454
+ if (cliTrustEnv !== undefined) {
455
+ return cliTrustEnv === 'true';
456
+ }
457
+ if (workspaceRoot) {
458
+ const initialSettings = loadSettings(workspaceRoot, false);
459
+ const { isTrusted } = checkPathTrust({
460
+ path: workspaceRoot,
461
+ isFolderTrustEnabled: initialSettings.folderTrust ?? true,
462
+ isHeadless: isHeadlessMode(),
463
+ });
464
+ return isTrusted ?? false;
465
+ }
466
+ return false;
467
+ }
468
+
469
+ export async function setTargetDir(
470
+ agentSettings: AgentSettings | undefined,
471
+ ): Promise<string> {
472
+ const originalCWD = process.cwd();
473
+ const targetDir =
474
+ getEnv('CODER_AGENT_WORKSPACE_PATH') ??
475
+ (agentSettings?.kind === CoderAgentEvent.StateAgentSettingsEvent
476
+ ? agentSettings.workspacePath
477
+ : undefined);
478
+
479
+ if (!targetDir) {
480
+ return originalCWD;
481
+ }
482
+
483
+ logger.info(
484
+ `[CoderAgentExecutor] Overriding workspace path to: ${targetDir}`,
485
+ );
486
+
487
+ try {
488
+ let resolvedPath: string;
489
+ try {
490
+ resolvedPath = resolveToRealPath(targetDir);
491
+ } catch (err: unknown) {
492
+ if (
493
+ err &&
494
+ typeof err === 'object' &&
495
+ 'code' in err &&
496
+ err.code === 'ENOENT'
497
+ ) {
498
+ const parentDir = path.dirname(path.resolve(targetDir));
499
+ resolvedPath = path.join(
500
+ resolveToRealPath(parentDir),
501
+ path.basename(targetDir),
502
+ );
503
+ } else {
504
+ throw err;
505
+ }
506
+ }
507
+
508
+ const isTestEnv =
509
+ process.env['VITEST'] === 'true' ||
510
+ process.env['NODE_ENV'] === 'test' ||
511
+ process.argv.some((arg) => arg.includes('vitest')) ||
512
+ resolvedPath.startsWith(resolveToRealPath(tmpdir()));
513
+
514
+ const allowedRoot = resolveToRealPath(
515
+ getEnv('CODER_AGENT_ALLOWED_ROOT') ||
516
+ (isTestEnv ? path.parse(resolvedPath).root : homedir()),
517
+ );
518
+ const relative = path.relative(allowedRoot, resolvedPath);
519
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
520
+ throw new Error(
521
+ `Workspace path ${resolvedPath} is outside the allowed root directory`,
522
+ );
523
+ }
524
+
525
+ let stats: fs.Stats;
526
+ try {
527
+ stats = await fs.promises.stat(resolvedPath);
528
+ } catch (err: unknown) {
529
+ if (
530
+ err &&
531
+ typeof err === 'object' &&
532
+ 'code' in err &&
533
+ err.code === 'ENOENT'
534
+ ) {
535
+ if (isTestEnv) {
536
+ await fs.promises.mkdir(resolvedPath, { recursive: true });
537
+ stats = await fs.promises.stat(resolvedPath);
538
+ } else {
539
+ throw new Error(`Workspace path ${resolvedPath} does not exist`);
540
+ }
541
+ } else {
542
+ throw err;
543
+ }
544
+ }
545
+
546
+ if (!stats.isDirectory()) {
547
+ throw new Error(`Workspace path ${resolvedPath} is not a directory`);
548
+ }
549
+
550
+ return resolvedPath;
551
+ } catch (e) {
552
+ logger.error(`[CoderAgentExecutor] Error resolving workspace path: ${e}`);
553
+ throw e;
554
+ }
555
+ }
556
+
557
+ export async function loadEnvironment(
558
+ isTrusted: boolean = false,
559
+ workspacePath: string = process.cwd(),
560
+ ): Promise<Record<string, string>> {
561
+ // For untrusted workspaces, we completely bypass workspace-level .env loading
562
+ // and only load environment variables from the user's trusted home directory.
563
+ let envFilePath: string | null = null;
564
+ if (isTrusted) {
565
+ envFilePath = await findEnvFile(workspacePath);
566
+ } else {
567
+ const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
568
+ try {
569
+ await fs.promises.access(homeGeminiEnvPath);
570
+ envFilePath = homeGeminiEnvPath;
571
+ } catch {
572
+ const homeEnvPath = path.join(homedir(), '.env');
573
+ try {
574
+ await fs.promises.access(homeEnvPath);
575
+ envFilePath = homeEnvPath;
576
+ } catch {
577
+ // Ignore
578
+ }
579
+ }
580
+ }
581
+ const envVars: Record<string, string> = {};
582
+ if (envFilePath) {
583
+ try {
584
+ const content = await fs.promises.readFile(envFilePath, 'utf-8');
585
+ const parsed = dotenv.parse(content);
586
+ for (const key in parsed) {
587
+ if (
588
+ Object.prototype.hasOwnProperty.call(parsed, key) &&
589
+ key !== '__proto__' &&
590
+ key !== 'constructor' &&
591
+ key !== 'prototype'
592
+ ) {
593
+ envVars[key] = parsed[key];
594
+ }
595
+ }
596
+ } catch {
597
+ // Ignore errors
598
+ }
599
+ }
600
+ return envVars;
601
+ }
602
+
603
+ async function findEnvFile(startDir: string): Promise<string | null> {
604
+ let currentDir = path.resolve(startDir);
605
+ while (true) {
606
+ // prefer gemini-specific .env under GEMINI_DIR
607
+ const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
608
+ try {
609
+ await fs.promises.access(geminiEnvPath);
610
+ return geminiEnvPath;
611
+ } catch {
612
+ // Ignore
613
+ }
614
+ const envPath = path.join(currentDir, '.env');
615
+ try {
616
+ await fs.promises.access(envPath);
617
+ return envPath;
618
+ } catch {
619
+ // Ignore
620
+ }
621
+ const parentDir = path.dirname(currentDir);
622
+ if (parentDir === currentDir || !parentDir) {
623
+ break;
624
+ }
625
+ currentDir = parentDir;
626
+ }
627
+ // check .env under home as fallback, again preferring gemini-specific .env
628
+ const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
629
+ try {
630
+ await fs.promises.access(homeGeminiEnvPath);
631
+ return homeGeminiEnvPath;
632
+ } catch {
633
+ // Ignore
634
+ }
635
+ const homeEnvPath = path.join(homedir(), '.env');
636
+ try {
637
+ await fs.promises.access(homeEnvPath);
638
+ return homeEnvPath;
639
+ } catch {
640
+ return null;
641
+ }
642
+ }
643
+
644
+ async function refreshAuthentication(
645
+ config: Config,
646
+ logPrefix: string,
647
+ envVars: Record<string, string>,
648
+ ): Promise<void> {
649
+ const getEnvLocal = (key: string) => envVars[key];
650
+
651
+ if (getEnvLocal('USE_CCPA')) {
652
+ logger.info(`[${logPrefix}] Using CCPA Auth:`);
653
+
654
+ logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`);
655
+ try {
656
+ await config.refreshAuth(AuthType.COMPUTE_ADC);
657
+ logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
658
+ } catch (adcError) {
659
+ const adcMessage =
660
+ adcError instanceof Error ? adcError.message : String(adcError);
661
+ logger.info(
662
+ `[${logPrefix}] COMPUTE_ADC failed or not available: ${adcMessage}`,
663
+ );
664
+
665
+ const useComputeAdc =
666
+ getEnvLocal('GEMINI_CLI_USE_COMPUTE_ADC') === 'true';
667
+ const isHeadless = isHeadlessMode();
668
+
669
+ if (isHeadless || useComputeAdc) {
670
+ const reason = isHeadless
671
+ ? 'headless mode'
672
+ : 'GEMINI_CLI_USE_COMPUTE_ADC=true';
673
+ throw new FatalAuthenticationError(
674
+ `COMPUTE_ADC failed: ${adcMessage}. (LOGIN_WITH_GOOGLE fallback skipped due to ${reason}. Run in an interactive terminal to use OAuth.)`,
675
+ );
676
+ }
677
+
678
+ logger.info(
679
+ `[${logPrefix}] COMPUTE_ADC failed, falling back to LOGIN_WITH_GOOGLE.`,
680
+ );
681
+ try {
682
+ await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
683
+ } catch (e) {
684
+ if (e instanceof FatalAuthenticationError) {
685
+ const originalMessage = e instanceof Error ? e.message : String(e);
686
+ throw new FatalAuthenticationError(
687
+ `${originalMessage}. The initial COMPUTE_ADC attempt also failed: ${adcMessage}`,
688
+ );
689
+ }
690
+ throw e;
691
+ }
692
+ }
693
+
694
+ logger.info(
695
+ `[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${getEnvLocal('GOOGLE_CLOUD_PROJECT')}`,
696
+ );
697
+ } else if (getEnvLocal('GEMINI_API_KEY')) {
698
+ logger.info(`[${logPrefix}] Using Gemini API Key`);
699
+ await config.refreshAuth(
700
+ AuthType.USE_GEMINI,
701
+ getEnvLocal('GEMINI_API_KEY'),
702
+ );
703
+ } else {
704
+ const errorMessage = `[${logPrefix}] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.`;
705
+ logger.error(errorMessage);
706
+ throw new Error(errorMessage);
707
+ }
708
+ }
packages/a2a-server/src/config/extension.ts ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ // Copied exactly from packages/cli/src/config/extension.ts, last PR #1026
8
+
9
+ import {
10
+ GEMINI_DIR,
11
+ type MCPServerConfig,
12
+ type ExtensionInstallMetadata,
13
+ type GeminiCLIExtension,
14
+ homedir,
15
+ } from '@google/gemini-cli-core';
16
+ import * as fs from 'node:fs';
17
+ import * as path from 'node:path';
18
+ import { logger } from '../utils/logger.js';
19
+
20
+ export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
21
+ export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
22
+ export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json';
23
+
24
+ /**
25
+ * Extension definition as written to disk in gemini-extension.json files.
26
+ * This should *not* be referenced outside of the logic for reading files.
27
+ * If information is required for manipulating extensions (load, unload, update)
28
+ * outside of the loading process that data needs to be stored on the
29
+ * GeminiCLIExtension class defined in Core.
30
+ */
31
+ interface ExtensionConfig {
32
+ name: string;
33
+ version: string;
34
+ mcpServers?: Record<string, MCPServerConfig>;
35
+ contextFileName?: string | string[];
36
+ excludeTools?: string[];
37
+ }
38
+
39
+ export function loadExtensions(
40
+ workspaceDir: string,
41
+ isTrusted: boolean = false,
42
+ ): GeminiCLIExtension[] {
43
+ const allExtensions = [
44
+ ...(isTrusted ? loadExtensionsFromDir(workspaceDir) : []),
45
+ ...loadExtensionsFromDir(homedir()),
46
+ ];
47
+
48
+ const uniqueExtensions: GeminiCLIExtension[] = [];
49
+ const seenNames = new Set<string>();
50
+ for (const extension of allExtensions) {
51
+ if (!seenNames.has(extension.name)) {
52
+ logger.info(
53
+ `Loading extension: ${extension.name} (version: ${extension.version})`,
54
+ );
55
+ uniqueExtensions.push(extension);
56
+ seenNames.add(extension.name);
57
+ }
58
+ }
59
+
60
+ return uniqueExtensions;
61
+ }
62
+
63
+ function loadExtensionsFromDir(dir: string): GeminiCLIExtension[] {
64
+ const extensionsDir = path.join(dir, EXTENSIONS_DIRECTORY_NAME);
65
+ if (!fs.existsSync(extensionsDir)) {
66
+ return [];
67
+ }
68
+
69
+ const extensions: GeminiCLIExtension[] = [];
70
+ for (const subdir of fs.readdirSync(extensionsDir)) {
71
+ const extensionDir = path.join(extensionsDir, subdir);
72
+
73
+ const extension = loadExtension(extensionDir);
74
+ if (extension != null) {
75
+ extensions.push(extension);
76
+ }
77
+ }
78
+ return extensions;
79
+ }
80
+
81
+ function loadExtension(extensionDir: string): GeminiCLIExtension | null {
82
+ if (!fs.statSync(extensionDir).isDirectory()) {
83
+ logger.error(
84
+ `Warning: unexpected file ${extensionDir} in extensions directory.`,
85
+ );
86
+ return null;
87
+ }
88
+
89
+ const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
90
+ if (!fs.existsSync(configFilePath)) {
91
+ logger.error(
92
+ `Warning: extension directory ${extensionDir} does not contain a config file ${configFilePath}.`,
93
+ );
94
+ return null;
95
+ }
96
+
97
+ try {
98
+ const configContent = fs.readFileSync(configFilePath, 'utf-8');
99
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
100
+ const config = JSON.parse(configContent) as ExtensionConfig;
101
+ if (!config.name || !config.version) {
102
+ logger.error(
103
+ `Invalid extension config in ${configFilePath}: missing name or version.`,
104
+ );
105
+ return null;
106
+ }
107
+
108
+ const installMetadata = loadInstallMetadata(extensionDir);
109
+
110
+ const contextFiles = getContextFileNames(config)
111
+ .map((contextFileName) => path.join(extensionDir, contextFileName))
112
+ .filter((contextFilePath) => fs.existsSync(contextFilePath));
113
+
114
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
115
+ return {
116
+ name: config.name,
117
+ version: config.version,
118
+ path: extensionDir,
119
+ contextFiles,
120
+ installMetadata,
121
+ mcpServers: config.mcpServers,
122
+ excludeTools: config.excludeTools,
123
+ isActive: true, // Barring any other signals extensions should be considered Active.
124
+ } as GeminiCLIExtension;
125
+ } catch (e) {
126
+ logger.error(
127
+ `Warning: error parsing extension config in ${configFilePath}: ${e}`,
128
+ );
129
+ return null;
130
+ }
131
+ }
132
+
133
+ function getContextFileNames(config: ExtensionConfig): string[] {
134
+ if (!config.contextFileName) {
135
+ return ['GEMINI.md'];
136
+ } else if (!Array.isArray(config.contextFileName)) {
137
+ return [config.contextFileName];
138
+ }
139
+ return config.contextFileName;
140
+ }
141
+
142
+ export function loadInstallMetadata(
143
+ extensionDir: string,
144
+ ): ExtensionInstallMetadata | undefined {
145
+ const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
146
+ try {
147
+ const configContent = fs.readFileSync(metadataFilePath, 'utf-8');
148
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
149
+ const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
150
+ return metadata;
151
+ } catch (e) {
152
+ logger.warn(
153
+ `Failed to load or parse extension install metadata at ${metadataFilePath}: ${e}`,
154
+ );
155
+ return undefined;
156
+ }
157
+ }
packages/a2a-server/src/config/rce_vulnerability.test.ts ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import * as os from 'node:os';
11
+
12
+ const mockHomeRef = vi.hoisted(() => ({ dir: '' }));
13
+
14
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
15
+ const original =
16
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
17
+ return {
18
+ ...original,
19
+ homedir: () => mockHomeRef.dir || os.tmpdir(),
20
+ };
21
+ });
22
+
23
+ import { loadEnvironment } from './config.js';
24
+
25
+ describe('Vulnerability Mitigation: b-519269096', () => {
26
+ let tempWorkspaceDir: string;
27
+
28
+ beforeEach(() => {
29
+ // Create a temporary home directory securely using mkdtempSync to ensure hermeticity
30
+ mockHomeRef.dir = fs.mkdtempSync(
31
+ path.join(os.tmpdir(), 'gemini-mock-home-'),
32
+ );
33
+
34
+ // Create a temporary workspace directory representing an untrusted repo
35
+ tempWorkspaceDir = fs.mkdtempSync(
36
+ path.join(os.tmpdir(), 'gemini-exploit-workspace-'),
37
+ );
38
+ const geminiDir = path.join(tempWorkspaceDir, '.gemini');
39
+ fs.mkdirSync(geminiDir, { recursive: true });
40
+
41
+ // Mock process.cwd to return the untrusted workspace
42
+ vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
43
+ });
44
+
45
+ afterEach(() => {
46
+ vi.unstubAllEnvs();
47
+ vi.restoreAllMocks();
48
+ fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
49
+ fs.rmSync(mockHomeRef.dir, { recursive: true, force: true });
50
+ });
51
+
52
+ it('should ignore GEMINI_CLI_TRUST_WORKSPACE and GEMINI_YOLO_MODE in untrusted workspaces', async () => {
53
+ const geminiDir = path.join(tempWorkspaceDir, '.gemini');
54
+ fs.writeFileSync(
55
+ path.join(geminiDir, '.env'),
56
+ 'GEMINI_CLI_TRUST_WORKSPACE=true\nGEMINI_YOLO_MODE=true\n',
57
+ );
58
+
59
+ // Ensure initially not set
60
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', '');
61
+ vi.stubEnv('GEMINI_YOLO_MODE', '');
62
+
63
+ // Act: load environment with isTrusted = false
64
+ const envVars = await loadEnvironment(false);
65
+
66
+ // Assert: In a SECURE system, these variables should NOT be loaded
67
+ expect(envVars['GEMINI_CLI_TRUST_WORKSPACE']).toBeUndefined();
68
+ expect(envVars['GEMINI_YOLO_MODE']).toBeUndefined();
69
+ expect(process.env['GEMINI_CLI_TRUST_WORKSPACE']).toBeFalsy();
70
+ expect(process.env['GEMINI_YOLO_MODE']).toBeFalsy();
71
+ });
72
+
73
+ it('should not load any variables from untrusted workspaces', async () => {
74
+ fs.writeFileSync(
75
+ path.join(tempWorkspaceDir, '.env'),
76
+ 'GEMINI_API_KEY=safe-key-123;rm -rf /\nGOOGLE_CLOUD_PROJECT=my-project\n',
77
+ );
78
+
79
+ // Ensure initially not set
80
+ vi.stubEnv('GEMINI_API_KEY', '');
81
+ vi.stubEnv('GOOGLE_CLOUD_PROJECT', '');
82
+
83
+ // Act: load environment with isTrusted = false
84
+ const envVars = await loadEnvironment(false);
85
+
86
+ // Assert: No variables should be loaded from the untrusted workspace
87
+ expect(envVars['GEMINI_API_KEY']).toBeUndefined();
88
+ expect(envVars['GOOGLE_CLOUD_PROJECT']).toBeUndefined();
89
+ expect(process.env['GEMINI_API_KEY']).toBeFalsy();
90
+ expect(process.env['GOOGLE_CLOUD_PROJECT']).toBeFalsy();
91
+ });
92
+
93
+ it('should load all variables in trusted workspaces with isolation', async () => {
94
+ const geminiDir = path.join(tempWorkspaceDir, '.gemini');
95
+ fs.writeFileSync(
96
+ path.join(geminiDir, '.env'),
97
+ 'GEMINI_CLI_TRUST_WORKSPACE=true\nGEMINI_YOLO_MODE=true\n',
98
+ );
99
+
100
+ // Ensure initially not set
101
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', '');
102
+ vi.stubEnv('GEMINI_YOLO_MODE', '');
103
+
104
+ // Load environment variables
105
+ const envVars = await loadEnvironment(true);
106
+
107
+ // Assert: In a trusted workspace, variables should be loaded
108
+ expect(envVars['GEMINI_CLI_TRUST_WORKSPACE']).toBe('true');
109
+ expect(envVars['GEMINI_YOLO_MODE']).toBe('true');
110
+
111
+ // Assert: Global process.env should NOT be polluted
112
+ expect(process.env['GEMINI_CLI_TRUST_WORKSPACE']).toBeFalsy();
113
+ expect(process.env['GEMINI_YOLO_MODE']).toBeFalsy();
114
+ });
115
+ });
packages/a2a-server/src/config/settings.test.ts ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import * as os from 'node:os';
11
+ import { loadSettings, USER_SETTINGS_PATH } from './settings.js';
12
+ import { debugLogger, checkPathTrust } from '@google/gemini-cli-core';
13
+
14
+ const mocks = vi.hoisted(() => {
15
+ const suffix = Math.random().toString(36).slice(2);
16
+ return {
17
+ suffix,
18
+ };
19
+ });
20
+
21
+ vi.mock('node:os', async (importOriginal) => {
22
+ const actual = await importOriginal<typeof import('node:os')>();
23
+ const path = await import('node:path');
24
+ return {
25
+ ...actual,
26
+ homedir: () => path.join(actual.tmpdir(), `gemini-home-${mocks.suffix}`),
27
+ };
28
+ });
29
+
30
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
31
+ const actual =
32
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
33
+ const path = await import('node:path');
34
+ const os = await import('node:os');
35
+ return {
36
+ ...actual,
37
+ GEMINI_DIR: '.gemini',
38
+ debugLogger: {
39
+ error: vi.fn(),
40
+ },
41
+ getErrorMessage: (error: unknown) => String(error),
42
+ homedir: () => path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`),
43
+ checkPathTrust: vi.fn(() => ({ isTrusted: false })),
44
+ isHeadlessMode: vi.fn(() => true),
45
+ };
46
+ });
47
+
48
+ describe('loadSettings', () => {
49
+ const mockHomeDir = path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`);
50
+ const mockWorkspaceDir = path.join(
51
+ os.tmpdir(),
52
+ `gemini-workspace-${mocks.suffix}`,
53
+ );
54
+ const mockGeminiHomeDir = path.join(mockHomeDir, '.gemini');
55
+ const mockGeminiWorkspaceDir = path.join(mockWorkspaceDir, '.gemini');
56
+
57
+ beforeEach(() => {
58
+ vi.clearAllMocks();
59
+ // Create the directories using the real fs
60
+ if (!fs.existsSync(mockGeminiHomeDir)) {
61
+ fs.mkdirSync(mockGeminiHomeDir, { recursive: true });
62
+ }
63
+ if (!fs.existsSync(mockGeminiWorkspaceDir)) {
64
+ fs.mkdirSync(mockGeminiWorkspaceDir, { recursive: true });
65
+ }
66
+
67
+ // Clean up settings files before each test
68
+ if (fs.existsSync(USER_SETTINGS_PATH)) {
69
+ fs.rmSync(USER_SETTINGS_PATH);
70
+ }
71
+ const workspaceSettingsPath = path.join(
72
+ mockGeminiWorkspaceDir,
73
+ 'settings.json',
74
+ );
75
+ if (fs.existsSync(workspaceSettingsPath)) {
76
+ fs.rmSync(workspaceSettingsPath);
77
+ }
78
+ });
79
+
80
+ afterEach(() => {
81
+ try {
82
+ if (fs.existsSync(mockHomeDir)) {
83
+ fs.rmSync(mockHomeDir, { recursive: true, force: true });
84
+ }
85
+ if (fs.existsSync(mockWorkspaceDir)) {
86
+ fs.rmSync(mockWorkspaceDir, { recursive: true, force: true });
87
+ }
88
+ } catch (e) {
89
+ debugLogger.error('Failed to cleanup temp dirs', e);
90
+ }
91
+ vi.restoreAllMocks();
92
+ });
93
+
94
+ it('should load other top-level settings correctly', () => {
95
+ const settings = {
96
+ showMemoryUsage: true,
97
+ tools: {
98
+ core: ['tool1', 'tool2'],
99
+ },
100
+ mcpServers: {
101
+ server1: {
102
+ command: 'cmd',
103
+ args: ['arg'],
104
+ },
105
+ },
106
+ fileFiltering: {
107
+ respectGitIgnore: true,
108
+ },
109
+ };
110
+ fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings));
111
+
112
+ const result = loadSettings(mockWorkspaceDir);
113
+ expect(result.showMemoryUsage).toBe(true);
114
+ expect(result.tools?.core).toEqual(['tool1', 'tool2']);
115
+ expect(result.mcpServers).toHaveProperty('server1');
116
+ expect(result.fileFiltering?.respectGitIgnore).toBe(true);
117
+ });
118
+
119
+ it('should load experimental settings correctly', () => {
120
+ const settings = {
121
+ experimental: {
122
+ enableAgents: true,
123
+ },
124
+ };
125
+ fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings));
126
+
127
+ const result = loadSettings(mockWorkspaceDir);
128
+ expect(result.experimental?.enableAgents).toBe(true);
129
+ });
130
+
131
+ it('should overwrite top-level settings from workspace (shallow merge)', () => {
132
+ const userSettings = {
133
+ showMemoryUsage: false,
134
+ fileFiltering: {
135
+ respectGitIgnore: true,
136
+ enableRecursiveFileSearch: true,
137
+ },
138
+ };
139
+ fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
140
+
141
+ const workspaceSettings = {
142
+ showMemoryUsage: true,
143
+ fileFiltering: {
144
+ respectGitIgnore: false,
145
+ },
146
+ };
147
+ const workspaceSettingsPath = path.join(
148
+ mockGeminiWorkspaceDir,
149
+ 'settings.json',
150
+ );
151
+ fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings));
152
+
153
+ const result = loadSettings(mockWorkspaceDir, true);
154
+ // Primitive value overwritten
155
+ expect(result.showMemoryUsage).toBe(true);
156
+
157
+ // Object value completely replaced (shallow merge behavior)
158
+ expect(result.fileFiltering?.respectGitIgnore).toBe(false);
159
+ expect(result.fileFiltering?.enableRecursiveFileSearch).toBeUndefined();
160
+ });
161
+
162
+ describe('security', () => {
163
+ it('should NOT load workspace settings if workspace is NOT trusted', () => {
164
+ const userSettings = { showMemoryUsage: false };
165
+ fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
166
+
167
+ const workspaceSettings = { showMemoryUsage: true };
168
+ const workspaceSettingsPath = path.join(
169
+ mockGeminiWorkspaceDir,
170
+ 'settings.json',
171
+ );
172
+ fs.writeFileSync(
173
+ workspaceSettingsPath,
174
+ JSON.stringify(workspaceSettings),
175
+ );
176
+
177
+ // checkPathTrust is mocked to return isTrusted: false by default
178
+ const result = loadSettings(mockWorkspaceDir);
179
+ expect(result.showMemoryUsage).toBe(false);
180
+ });
181
+
182
+ it('should load workspace settings if workspace IS trusted', () => {
183
+ vi.mocked(checkPathTrust).mockReturnValueOnce({
184
+ isTrusted: true,
185
+ source: 'file',
186
+ });
187
+ const userSettings = { showMemoryUsage: false };
188
+ fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
189
+
190
+ const workspaceSettings = { showMemoryUsage: true };
191
+ const workspaceSettingsPath = path.join(
192
+ mockGeminiWorkspaceDir,
193
+ 'settings.json',
194
+ );
195
+ fs.writeFileSync(
196
+ workspaceSettingsPath,
197
+ JSON.stringify(workspaceSettings),
198
+ );
199
+
200
+ const result = loadSettings(mockWorkspaceDir);
201
+ expect(result.showMemoryUsage).toBe(true);
202
+ });
203
+
204
+ it('should NOT allow workspace settings to override adminPolicyPaths or policyPaths even if trusted', () => {
205
+ vi.mocked(checkPathTrust).mockReturnValueOnce({
206
+ isTrusted: true,
207
+ source: 'file',
208
+ });
209
+ const userSettings = {
210
+ adminPolicyPaths: ['/trusted/admin'],
211
+ policyPaths: ['/trusted/user'],
212
+ };
213
+ fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
214
+
215
+ const workspaceSettings = {
216
+ adminPolicyPaths: ['./malicious/admin'],
217
+ policyPaths: ['./malicious/user'],
218
+ showMemoryUsage: true,
219
+ };
220
+ const workspaceSettingsPath = path.join(
221
+ mockGeminiWorkspaceDir,
222
+ 'settings.json',
223
+ );
224
+ fs.writeFileSync(
225
+ workspaceSettingsPath,
226
+ JSON.stringify(workspaceSettings),
227
+ );
228
+
229
+ const result = loadSettings(mockWorkspaceDir);
230
+ expect(result.showMemoryUsage).toBe(true);
231
+ expect(result.adminPolicyPaths).toEqual(['/trusted/admin']);
232
+ expect(result.policyPaths).toEqual(['/trusted/user']);
233
+ });
234
+ });
235
+ });
packages/a2a-server/src/config/settings.ts ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+
10
+ import {
11
+ type MCPServerConfig,
12
+ debugLogger,
13
+ GEMINI_DIR,
14
+ getErrorMessage,
15
+ type TelemetrySettings,
16
+ homedir,
17
+ checkPathTrust,
18
+ isHeadlessMode,
19
+ } from '@google/gemini-cli-core';
20
+ import stripJsonComments from 'strip-json-comments';
21
+
22
+ export const USER_SETTINGS_DIR = path.join(homedir(), GEMINI_DIR);
23
+ export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json');
24
+
25
+ // TODO: Ensure full compatibility with V2 nested settings structure (settings.schema.json).
26
+ // This involves updating the interface and implementing migration logic to support legacy V1 (flat) settings,
27
+ // similar to how packages/cli/src/config/settings.ts handles it.
28
+ export interface Settings {
29
+ mcpServers?: Record<string, MCPServerConfig>;
30
+ tools?: {
31
+ allowed?: string[];
32
+ exclude?: string[];
33
+ core?: string[];
34
+ };
35
+ telemetry?: TelemetrySettings;
36
+ showMemoryUsage?: boolean;
37
+ checkpointing?: CheckpointingSettings;
38
+ folderTrust?: boolean;
39
+ general?: {
40
+ previewFeatures?: boolean;
41
+ };
42
+
43
+ // Git-aware file filtering settings
44
+ fileFiltering?: {
45
+ respectGitIgnore?: boolean;
46
+ respectGeminiIgnore?: boolean;
47
+ enableRecursiveFileSearch?: boolean;
48
+ customIgnoreFilePaths?: string[];
49
+ };
50
+ experimental?: {
51
+ enableAgents?: boolean;
52
+ };
53
+ policyPaths?: string[];
54
+ adminPolicyPaths?: string[];
55
+ }
56
+
57
+ export interface SettingsError {
58
+ message: string;
59
+ path: string;
60
+ }
61
+
62
+ export interface CheckpointingSettings {
63
+ enabled?: boolean;
64
+ }
65
+
66
+ /**
67
+ * Loads settings from user and workspace directories.
68
+ * Project settings override user settings if the workspace is trusted.
69
+ *
70
+ * How is it different to gemini-cli/cli: Returns already merged settings rather
71
+ * than `LoadedSettings` (unnecessary since we are not modifying users
72
+ * settings.json).
73
+ */
74
+ export function loadSettings(
75
+ workspaceDir: string,
76
+ isTrustedOverride?: boolean,
77
+ ): Settings {
78
+ let userSettings: Settings = {};
79
+ let workspaceSettings: Settings = {};
80
+ const settingsErrors: SettingsError[] = [];
81
+
82
+ // Load user settings
83
+ try {
84
+ if (fs.existsSync(USER_SETTINGS_PATH)) {
85
+ const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
86
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
87
+ const parsedUserSettings = JSON.parse(
88
+ stripJsonComments(userContent),
89
+ ) as Settings;
90
+ userSettings = resolveEnvVarsInObject(parsedUserSettings);
91
+ }
92
+ } catch (error: unknown) {
93
+ settingsErrors.push({
94
+ message: getErrorMessage(error),
95
+ path: USER_SETTINGS_PATH,
96
+ });
97
+ }
98
+
99
+ let isTrusted = isTrustedOverride;
100
+ if (isTrusted === undefined) {
101
+ const { isTrusted: trustResult } = checkPathTrust({
102
+ path: workspaceDir,
103
+ isFolderTrustEnabled: userSettings.folderTrust ?? true,
104
+ isHeadless: isHeadlessMode(),
105
+ });
106
+ isTrusted = trustResult ?? false;
107
+ }
108
+
109
+ const workspaceSettingsPath = path.join(
110
+ workspaceDir,
111
+ GEMINI_DIR,
112
+ 'settings.json',
113
+ );
114
+
115
+ // Load workspace settings only if trusted
116
+ if (isTrusted) {
117
+ try {
118
+ if (fs.existsSync(workspaceSettingsPath)) {
119
+ const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
120
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
121
+ const parsedWorkspaceSettings = JSON.parse(
122
+ stripJsonComments(projectContent),
123
+ ) as Settings;
124
+ workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
125
+ }
126
+ } catch (error: unknown) {
127
+ settingsErrors.push({
128
+ message: getErrorMessage(error),
129
+ path: workspaceSettingsPath,
130
+ });
131
+ }
132
+ }
133
+
134
+ if (settingsErrors.length > 0) {
135
+ debugLogger.error('Errors loading settings:');
136
+ for (const error of settingsErrors) {
137
+ debugLogger.error(` Path: ${error.path}`);
138
+ debugLogger.error(` Message: ${error.message}`);
139
+ }
140
+ }
141
+
142
+ // If there are overlapping keys, the values of workspaceSettings will
143
+ // override values from userSettings
144
+ const mergedSettings = {
145
+ ...userSettings,
146
+ ...workspaceSettings,
147
+ };
148
+
149
+ // Security: ensure policyPaths and adminPolicyPaths are only loaded from trusted, user-level
150
+ // configuration and cannot be overridden by workspace-level settings, even if the
151
+ // workspace is trusted.
152
+ mergedSettings.policyPaths = userSettings.policyPaths;
153
+ mergedSettings.adminPolicyPaths = userSettings.adminPolicyPaths;
154
+
155
+ return mergedSettings;
156
+ }
157
+
158
+ function resolveEnvVarsInString(value: string): string {
159
+ const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME}
160
+ return value.replace(
161
+ envVarRegex,
162
+ (match: string, varName1: string, varName2: string) => {
163
+ const varName = varName1 || varName2;
164
+ const envValue = process?.env?.[varName];
165
+ if (typeof envValue === 'string') {
166
+ return envValue;
167
+ }
168
+ return match;
169
+ },
170
+ );
171
+ }
172
+
173
+ function resolveEnvVarsInObject<T>(obj: T): T {
174
+ if (
175
+ obj === null ||
176
+ obj === undefined ||
177
+ typeof obj === 'boolean' ||
178
+ typeof obj === 'number'
179
+ ) {
180
+ return obj;
181
+ }
182
+
183
+ if (typeof obj === 'string') {
184
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
185
+ return resolveEnvVarsInString(obj) as unknown as T;
186
+ }
187
+
188
+ if (Array.isArray(obj)) {
189
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
190
+ return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T;
191
+ }
192
+
193
+ if (typeof obj === 'object') {
194
+ const newObj = { ...obj } as T;
195
+ for (const key in newObj) {
196
+ if (Object.prototype.hasOwnProperty.call(newObj, key)) {
197
+ newObj[key] = resolveEnvVarsInObject(newObj[key]);
198
+ }
199
+ }
200
+ return newObj;
201
+ }
202
+
203
+ return obj;
204
+ }
packages/a2a-server/src/config/workspace_trust.test.ts ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+ vi.unmock('@google/gemini-cli-core');
9
+ import * as fs from 'node:fs';
10
+ import * as path from 'node:path';
11
+ import * as os from 'node:os';
12
+ import {
13
+ Config,
14
+ checkPathTrust,
15
+ SimpleExtensionLoader,
16
+ } from '@google/gemini-cli-core';
17
+ import * as core from '@google/gemini-cli-core';
18
+ import { loadConfig } from './config.js';
19
+ import type { Settings } from './settings.js';
20
+
21
+ describe('Workspace Trust Evaluation', () => {
22
+ let tempWorkspaceDir: string;
23
+
24
+ beforeEach(() => {
25
+ tempWorkspaceDir = fs.mkdtempSync(
26
+ path.join(os.tmpdir(), 'gemini-trust-eval-'),
27
+ );
28
+ const geminiDir = path.join(tempWorkspaceDir, '.gemini');
29
+ fs.mkdirSync(geminiDir, { recursive: true });
30
+
31
+ vi.stubEnv('GEMINI_API_KEY', 'dummy-key-for-eval');
32
+ vi.spyOn(Config.prototype, 'initialize').mockResolvedValue(undefined);
33
+ vi.spyOn(Config.prototype, 'waitForMcpInit').mockResolvedValue(undefined);
34
+ });
35
+
36
+ afterEach(() => {
37
+ vi.unstubAllEnvs();
38
+ vi.restoreAllMocks();
39
+ fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
40
+ });
41
+
42
+ it('resolves untrusted environment signals in checkPathTrust', () => {
43
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'false');
44
+
45
+ const result = checkPathTrust({
46
+ path: tempWorkspaceDir,
47
+ isFolderTrustEnabled: true,
48
+ });
49
+
50
+ expect(result).toEqual({ isTrusted: false, source: 'env' });
51
+
52
+ vi.stubEnv('GEMINI_RESTRICTED_MODE', 'true');
53
+
54
+ const restrictedResult = checkPathTrust({
55
+ path: tempWorkspaceDir,
56
+ isFolderTrustEnabled: true,
57
+ });
58
+
59
+ expect(restrictedResult).toEqual({ isTrusted: false, source: 'env' });
60
+ });
61
+
62
+ it('evaluates to false in Config.isTrustedFolder when untrusted signals are present or undefined', () => {
63
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'false');
64
+
65
+ const untrustedConfig = new Config({
66
+ sessionId: 'test-session',
67
+ clientName: 'test-client',
68
+ model: 'gemini-pro',
69
+ targetDir: tempWorkspaceDir,
70
+ cwd: tempWorkspaceDir,
71
+ debugMode: false,
72
+ question: '',
73
+ folderTrust: false,
74
+ trustedFolder: undefined,
75
+ });
76
+
77
+ expect(untrustedConfig.isTrustedFolder()).toBe(false);
78
+
79
+ vi.unstubAllEnvs();
80
+ vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', '');
81
+
82
+ const fallbackConfig = new Config({
83
+ sessionId: 'test-session',
84
+ clientName: 'test-client',
85
+ model: 'gemini-pro',
86
+ targetDir: tempWorkspaceDir,
87
+ cwd: tempWorkspaceDir,
88
+ debugMode: false,
89
+ question: '',
90
+ folderTrust: true,
91
+ trustedFolder: undefined,
92
+ });
93
+
94
+ expect(fallbackConfig.isTrustedFolder()).toBe(false);
95
+ });
96
+
97
+ it('assigns undefined to mcpServers, policyPaths, adminPolicyPaths, tools, and telemetry in configParams and policySettings when trusted is false', async () => {
98
+ const policySpy = vi.spyOn(core, 'createPolicyEngineConfig');
99
+ const settings: Settings = {
100
+ mcpServers: {
101
+ 'test-tool': {
102
+ command: 'node',
103
+ args: ['-e', 'process.exit(0)'],
104
+ },
105
+ },
106
+ policyPaths: ['/test/policy/path'],
107
+ adminPolicyPaths: ['/test/admin/policy/path'],
108
+ tools: {
109
+ allowed: ['shell'],
110
+ },
111
+ telemetry: {
112
+ otlpEndpoint: 'http://untrusted-endpoint:4317',
113
+ },
114
+ };
115
+
116
+ const config = await loadConfig(
117
+ settings,
118
+ new SimpleExtensionLoader([]),
119
+ 'test-untrusted-task',
120
+ false,
121
+ tempWorkspaceDir,
122
+ );
123
+
124
+ expect(config.getMcpServers()).toBeUndefined();
125
+ expect(policySpy.mock.calls[0][0].mcpServers).toBeUndefined();
126
+ expect(policySpy.mock.calls[0][0].policyPaths).toBeUndefined();
127
+ expect(policySpy.mock.calls[0][0].adminPolicyPaths).toBeUndefined();
128
+ expect(policySpy.mock.calls[0][0].tools?.allowed).toBeUndefined();
129
+ expect(config.getTelemetryOtlpEndpoint()).not.toBe(
130
+ 'http://untrusted-endpoint:4317',
131
+ );
132
+ expect(settings.mcpServers).toBeDefined();
133
+ expect(settings.policyPaths).toBeDefined();
134
+ expect(settings.adminPolicyPaths).toBeDefined();
135
+ expect(settings.tools).toBeDefined();
136
+ expect(settings.telemetry).toBeDefined();
137
+ });
138
+
139
+ it('retains mcpServers, policyPaths, adminPolicyPaths, tools, and telemetry in configParams and policySettings when trusted is true', async () => {
140
+ const policySpy = vi.spyOn(core, 'createPolicyEngineConfig');
141
+ const settings: Settings = {
142
+ mcpServers: {
143
+ 'test-tool': {
144
+ command: 'node',
145
+ args: ['-e', 'process.exit(0)'],
146
+ },
147
+ },
148
+ policyPaths: ['/test/policy/path'],
149
+ adminPolicyPaths: ['/test/admin/policy/path'],
150
+ tools: {
151
+ allowed: ['shell'],
152
+ },
153
+ telemetry: {
154
+ otlpEndpoint: 'http://trusted-endpoint:4317',
155
+ },
156
+ };
157
+
158
+ const config = await loadConfig(
159
+ settings,
160
+ new SimpleExtensionLoader([]),
161
+ 'test-trusted-task',
162
+ true,
163
+ tempWorkspaceDir,
164
+ );
165
+
166
+ expect(config.getMcpServers()).toEqual(settings.mcpServers);
167
+ expect(policySpy.mock.calls[0][0].mcpServers).toEqual(settings.mcpServers);
168
+ expect(policySpy.mock.calls[0][0].policyPaths).toEqual(
169
+ settings.policyPaths,
170
+ );
171
+ expect(policySpy.mock.calls[0][0].adminPolicyPaths).toEqual(
172
+ settings.adminPolicyPaths,
173
+ );
174
+ expect(policySpy.mock.calls[0][0].tools?.allowed).toEqual(['shell']);
175
+ expect(config.getTelemetryOtlpEndpoint()).toBe(
176
+ 'http://trusted-endpoint:4317',
177
+ );
178
+ });
179
+ });
packages/a2a-server/src/http/app.test.ts ADDED
@@ -0,0 +1,1266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ GeminiEventType,
9
+ ApprovalMode,
10
+ type Config,
11
+ type ToolCallConfirmationDetails,
12
+ } from '@google/gemini-cli-core';
13
+ import type {
14
+ TaskStatusUpdateEvent,
15
+ SendStreamingMessageSuccessResponse,
16
+ } from '@a2a-js/sdk';
17
+ import express from 'express';
18
+ import type { Server } from 'node:http';
19
+ import request from 'supertest';
20
+ import {
21
+ afterAll,
22
+ afterEach,
23
+ beforeEach,
24
+ beforeAll,
25
+ describe,
26
+ expect,
27
+ it,
28
+ vi,
29
+ } from 'vitest';
30
+ import { createApp, main } from './app.js';
31
+ import { commandRegistry } from '../commands/command-registry.js';
32
+ import {
33
+ assertUniqueFinalEventIsLast,
34
+ assertTaskCreationAndWorkingStatus,
35
+ createStreamMessageRequest,
36
+ createMockConfig,
37
+ } from '../utils/testing_utils.js';
38
+ // Import MockTool from specific path to avoid vitest dependency in main core bundle
39
+ import { MockTool } from '@google/gemini-cli-core/src/test-utils/mock-tool.js';
40
+ import type { Command, CommandContext } from '../commands/types.js';
41
+
42
+ const mockToolConfirmationFn = async () =>
43
+ ({}) as unknown as ToolCallConfirmationDetails;
44
+
45
+ const streamToSSEEvents = (
46
+ stream: string,
47
+ ): SendStreamingMessageSuccessResponse[] =>
48
+ stream
49
+ .split('\n\n')
50
+ .filter(Boolean) // Remove empty strings from trailing newlines
51
+ .map((chunk) => {
52
+ const dataLine = chunk
53
+ .split('\n')
54
+ .find((line) => line.startsWith('data: '));
55
+ if (!dataLine) {
56
+ throw new Error(`Invalid SSE chunk found: "${chunk}"`);
57
+ }
58
+ return JSON.parse(dataLine.substring(6));
59
+ });
60
+
61
+ // Mock the logger to avoid polluting test output
62
+ // Comment out to debug tests
63
+ vi.mock('../utils/logger.js', () => ({
64
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
65
+ }));
66
+
67
+ let config: Config;
68
+ const getToolRegistrySpy = vi.fn().mockReturnValue({
69
+ getTool: vi.fn(),
70
+ getAllToolNames: vi.fn().mockReturnValue([]),
71
+ getAllTools: vi.fn().mockReturnValue([]),
72
+ getToolsByServer: vi.fn().mockReturnValue([]),
73
+ });
74
+ const getApprovalModeSpy = vi.fn();
75
+ const getShellExecutionConfigSpy = vi.fn();
76
+ const getExtensionsSpy = vi.fn();
77
+
78
+ vi.mock('../config/config.js', async () => {
79
+ const actual = await vi.importActual('../config/config.js');
80
+ return {
81
+ ...actual,
82
+ loadConfig: vi.fn().mockImplementation(async () => {
83
+ const mockConfig = createMockConfig({
84
+ getToolRegistry: getToolRegistrySpy,
85
+ getApprovalMode: getApprovalModeSpy,
86
+ getShellExecutionConfig: getShellExecutionConfigSpy,
87
+ getExtensions: getExtensionsSpy,
88
+ });
89
+ config = mockConfig as Config;
90
+ return config;
91
+ }),
92
+ };
93
+ });
94
+
95
+ // Mock the GeminiClient to avoid actual API calls
96
+ const sendMessageStreamSpy = vi.fn();
97
+ vi.mock('@google/gemini-cli-core', async () => {
98
+ const actual = await vi.importActual('@google/gemini-cli-core');
99
+ return {
100
+ ...actual,
101
+ GeminiClient: vi.fn().mockImplementation(() => ({
102
+ sendMessageStream: sendMessageStreamSpy,
103
+ getUserTier: vi.fn().mockReturnValue('free'),
104
+ initialize: vi.fn(),
105
+ })),
106
+ performRestore: vi.fn(),
107
+ };
108
+ });
109
+
110
+ describe('E2E Tests', () => {
111
+ let app: express.Express;
112
+ let server: Server;
113
+
114
+ beforeAll(async () => {
115
+ app = await createApp();
116
+ server = app.listen(0); // Listen on a random available port
117
+ });
118
+
119
+ beforeEach(() => {
120
+ getApprovalModeSpy.mockReturnValue(ApprovalMode.DEFAULT);
121
+ });
122
+
123
+ afterAll(
124
+ () =>
125
+ new Promise<void>((resolve) => {
126
+ server.close(() => {
127
+ resolve();
128
+ });
129
+ }),
130
+ );
131
+
132
+ afterEach(() => {
133
+ vi.clearAllMocks();
134
+ vi.unstubAllEnvs();
135
+ });
136
+
137
+ it('should create a new task and stream status updates (text-content) via POST /', async () => {
138
+ sendMessageStreamSpy.mockImplementation(async function* () {
139
+ yield* [{ type: 'content', value: 'Hello how are you?' }];
140
+ });
141
+
142
+ const agent = request.agent(app);
143
+ const res = await agent
144
+ .post('/')
145
+ .send(createStreamMessageRequest('hello', 'a2a-test-message'))
146
+ .set('Content-Type', 'application/json')
147
+ .expect(200);
148
+
149
+ const events = streamToSSEEvents(res.text);
150
+
151
+ assertTaskCreationAndWorkingStatus(events);
152
+
153
+ // Status update: text-content
154
+ const textContentEvent = events[2].result as TaskStatusUpdateEvent;
155
+ expect(textContentEvent.kind).toBe('status-update');
156
+ expect(textContentEvent.status.state).toBe('working');
157
+ expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({
158
+ kind: 'text-content',
159
+ });
160
+ expect(textContentEvent.status.message?.parts).toMatchObject([
161
+ { kind: 'text', text: 'Hello how are you?' },
162
+ ]);
163
+
164
+ // Status update: input-required (final)
165
+ const finalEvent = events[3].result as TaskStatusUpdateEvent;
166
+ expect(finalEvent.kind).toBe('status-update');
167
+ expect(finalEvent.status?.state).toBe('input-required');
168
+ expect(finalEvent.final).toBe(true);
169
+
170
+ assertUniqueFinalEventIsLast(events);
171
+ expect(events.length).toBe(4);
172
+ });
173
+
174
+ it('should create a new task, schedule a tool call, and wait for approval', async () => {
175
+ // First call yields the tool request
176
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
177
+ yield* [
178
+ {
179
+ type: GeminiEventType.ToolCallRequest,
180
+ value: {
181
+ callId: 'test-call-id',
182
+ name: 'test-tool',
183
+ args: {},
184
+ },
185
+ },
186
+ ];
187
+ });
188
+ // Subsequent calls yield nothing
189
+ sendMessageStreamSpy.mockImplementation(async function* () {
190
+ yield* [];
191
+ });
192
+
193
+ const mockTool = new MockTool({
194
+ name: 'test-tool',
195
+ shouldConfirmExecute: vi.fn(mockToolConfirmationFn),
196
+ });
197
+
198
+ getToolRegistrySpy.mockReturnValue({
199
+ getAllTools: vi.fn().mockReturnValue([mockTool]),
200
+ getToolsByServer: vi.fn().mockReturnValue([]),
201
+ getTool: vi.fn().mockReturnValue(mockTool),
202
+ });
203
+
204
+ const agent = request.agent(app);
205
+ const res = await agent
206
+ .post('/')
207
+ .send(createStreamMessageRequest('run a tool', 'a2a-tool-test-message'))
208
+ .set('Content-Type', 'application/json')
209
+ .expect(200);
210
+
211
+ const events = streamToSSEEvents(res.text);
212
+ assertTaskCreationAndWorkingStatus(events);
213
+
214
+ // Status update: working
215
+ const workingEvent2 = events[2].result as TaskStatusUpdateEvent;
216
+ expect(workingEvent2.kind).toBe('status-update');
217
+ expect(workingEvent2.status.state).toBe('working');
218
+ expect(workingEvent2.metadata?.['coderAgent']).toMatchObject({
219
+ kind: 'state-change',
220
+ });
221
+
222
+ // Status update: tool-call-update
223
+ const toolCallUpdateEvent = events[3].result as TaskStatusUpdateEvent;
224
+ expect(toolCallUpdateEvent.kind).toBe('status-update');
225
+ expect(toolCallUpdateEvent.status.state).toBe('working');
226
+ expect(toolCallUpdateEvent.metadata?.['coderAgent']).toMatchObject({
227
+ kind: 'tool-call-update',
228
+ });
229
+ expect(toolCallUpdateEvent.status.message?.parts).toMatchObject([
230
+ {
231
+ data: {
232
+ status: 'scheduled',
233
+ request: { callId: 'test-call-id' },
234
+ },
235
+ },
236
+ ]);
237
+
238
+ // State update: awaiting_approval update
239
+ const toolCallConfirmationEvent = events[4].result as TaskStatusUpdateEvent;
240
+ expect(toolCallConfirmationEvent.kind).toBe('status-update');
241
+ expect(toolCallConfirmationEvent.metadata?.['coderAgent']).toMatchObject({
242
+ kind: 'tool-call-confirmation',
243
+ });
244
+ expect(toolCallConfirmationEvent.status.message?.parts).toMatchObject([
245
+ {
246
+ data: {
247
+ status: 'awaiting_approval',
248
+ request: { callId: 'test-call-id' },
249
+ },
250
+ },
251
+ ]);
252
+ expect(toolCallConfirmationEvent.status?.state).toBe('working');
253
+
254
+ assertUniqueFinalEventIsLast(events);
255
+ expect(events.length).toBe(6);
256
+ });
257
+
258
+ it('should handle multiple tool calls in a single turn', async () => {
259
+ // First call yields the tool request
260
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
261
+ yield* [
262
+ {
263
+ type: GeminiEventType.ToolCallRequest,
264
+ value: {
265
+ callId: 'test-call-id-1',
266
+ name: 'test-tool-1',
267
+ args: {},
268
+ },
269
+ },
270
+ {
271
+ type: GeminiEventType.ToolCallRequest,
272
+ value: {
273
+ callId: 'test-call-id-2',
274
+ name: 'test-tool-2',
275
+ args: {},
276
+ },
277
+ },
278
+ ];
279
+ });
280
+ // Subsequent calls yield nothing
281
+ sendMessageStreamSpy.mockImplementation(async function* () {
282
+ yield* [];
283
+ });
284
+
285
+ const mockTool1 = new MockTool({
286
+ name: 'test-tool-1',
287
+ displayName: 'Test Tool 1',
288
+ shouldConfirmExecute: vi.fn(mockToolConfirmationFn),
289
+ });
290
+ const mockTool2 = new MockTool({
291
+ name: 'test-tool-2',
292
+ displayName: 'Test Tool 2',
293
+ shouldConfirmExecute: vi.fn(mockToolConfirmationFn),
294
+ });
295
+
296
+ getToolRegistrySpy.mockReturnValue({
297
+ getAllTools: vi.fn().mockReturnValue([mockTool1, mockTool2]),
298
+ getToolsByServer: vi.fn().mockReturnValue([]),
299
+ getTool: vi.fn().mockImplementation((name: string) => {
300
+ if (name === 'test-tool-1') return mockTool1;
301
+ if (name === 'test-tool-2') return mockTool2;
302
+ return undefined;
303
+ }),
304
+ });
305
+
306
+ const agent = request.agent(app);
307
+ const res = await agent
308
+ .post('/')
309
+ .send(
310
+ createStreamMessageRequest(
311
+ 'run two tools',
312
+ 'a2a-multi-tool-test-message',
313
+ ),
314
+ )
315
+ .set('Content-Type', 'application/json')
316
+ .expect(200);
317
+
318
+ const events = streamToSSEEvents(res.text);
319
+ assertTaskCreationAndWorkingStatus(events);
320
+
321
+ // Second working update
322
+ const workingEvent = events[2].result as TaskStatusUpdateEvent;
323
+ expect(workingEvent.kind).toBe('status-update');
324
+ expect(workingEvent.status.state).toBe('working');
325
+
326
+ // State Update: Validate the first tool call
327
+ const toolCallValidateEvent1 = events[3].result as TaskStatusUpdateEvent;
328
+ expect(toolCallValidateEvent1.metadata?.['coderAgent']).toMatchObject({
329
+ kind: 'tool-call-update',
330
+ });
331
+ expect(toolCallValidateEvent1.status.message?.parts).toMatchObject([
332
+ {
333
+ data: {
334
+ status: 'scheduled',
335
+ request: { callId: 'test-call-id-1' },
336
+ },
337
+ },
338
+ ]);
339
+
340
+ // --- Assert the event stream ---
341
+ // 1. Initial "submitted" status.
342
+ expect((events[0].result as TaskStatusUpdateEvent).status.state).toBe(
343
+ 'submitted',
344
+ );
345
+
346
+ // 2. "working" status after receiving the user prompt.
347
+ expect((events[1].result as TaskStatusUpdateEvent).status.state).toBe(
348
+ 'working',
349
+ );
350
+
351
+ // 3. A "state-change" event from the agent.
352
+ expect(events[2].result.metadata?.['coderAgent']).toMatchObject({
353
+ kind: 'state-change',
354
+ });
355
+
356
+ // 4. Tool 1 is scheduled.
357
+ const toolCallUpdate1 = events[3].result as TaskStatusUpdateEvent;
358
+ expect(toolCallUpdate1.metadata?.['coderAgent']).toMatchObject({
359
+ kind: 'tool-call-update',
360
+ });
361
+ expect(toolCallUpdate1.status.message?.parts).toMatchObject([
362
+ {
363
+ data: {
364
+ request: { callId: 'test-call-id-1' },
365
+ status: 'scheduled',
366
+ },
367
+ },
368
+ ]);
369
+
370
+ // 5. Tool 2 is scheduled.
371
+ const toolCallUpdate2 = events[4].result as TaskStatusUpdateEvent;
372
+ expect(toolCallUpdate2.metadata?.['coderAgent']).toMatchObject({
373
+ kind: 'tool-call-update',
374
+ });
375
+ expect(toolCallUpdate2.status.message?.parts).toMatchObject([
376
+ {
377
+ data: {
378
+ request: { callId: 'test-call-id-2' },
379
+ status: 'scheduled',
380
+ },
381
+ },
382
+ ]);
383
+
384
+ // 6. Tool 1 is awaiting approval.
385
+ const toolCallAwaitEvent1 = events[5].result as TaskStatusUpdateEvent;
386
+ expect(toolCallAwaitEvent1.metadata?.['coderAgent']).toMatchObject({
387
+ kind: 'tool-call-confirmation',
388
+ });
389
+ expect(toolCallAwaitEvent1.status.message?.parts).toMatchObject([
390
+ {
391
+ data: {
392
+ request: { callId: 'test-call-id-1' },
393
+ status: 'awaiting_approval',
394
+ },
395
+ },
396
+ ]);
397
+
398
+ // 7. Tool 2 is awaiting approval.
399
+ const toolCallAwaitEvent2 = events[6].result as TaskStatusUpdateEvent;
400
+ expect(toolCallAwaitEvent2.metadata?.['coderAgent']).toMatchObject({
401
+ kind: 'tool-call-confirmation',
402
+ });
403
+ expect(toolCallAwaitEvent2.status.message?.parts).toMatchObject([
404
+ {
405
+ data: {
406
+ request: { callId: 'test-call-id-2' },
407
+ status: 'awaiting_approval',
408
+ },
409
+ },
410
+ ]);
411
+
412
+ // 8. The final event is "input-required".
413
+ const finalEvent = events[7].result as TaskStatusUpdateEvent;
414
+ expect(finalEvent.final).toBe(true);
415
+ expect(finalEvent.status.state).toBe('input-required');
416
+
417
+ // The scheduler now waits for approval, so no more events are sent.
418
+ assertUniqueFinalEventIsLast(events);
419
+ expect(events.length).toBe(8);
420
+ });
421
+
422
+ it('should handle multiple tool calls sequentially in YOLO mode', async () => {
423
+ // Set YOLO mode to auto-approve tools and test sequential execution.
424
+ getApprovalModeSpy.mockReturnValue(ApprovalMode.YOLO);
425
+
426
+ // First call yields the tool request
427
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
428
+ yield* [
429
+ {
430
+ type: GeminiEventType.ToolCallRequest,
431
+ value: {
432
+ callId: 'test-call-id-1',
433
+ name: 'test-tool-1',
434
+ args: {},
435
+ },
436
+ },
437
+ {
438
+ type: GeminiEventType.ToolCallRequest,
439
+ value: {
440
+ callId: 'test-call-id-2',
441
+ name: 'test-tool-2',
442
+ args: {},
443
+ },
444
+ },
445
+ ];
446
+ });
447
+ // Subsequent calls yield nothing, as the tools will "succeed".
448
+ sendMessageStreamSpy.mockImplementation(async function* () {
449
+ yield* [{ type: 'content', value: 'All tools executed.' }];
450
+ });
451
+
452
+ const mockTool1 = new MockTool({
453
+ name: 'test-tool-1',
454
+ displayName: 'Test Tool 1',
455
+ shouldConfirmExecute: vi.fn(mockToolConfirmationFn),
456
+ execute: vi
457
+ .fn()
458
+ .mockResolvedValue({ llmContent: 'tool 1 done', returnDisplay: '' }),
459
+ });
460
+ const mockTool2 = new MockTool({
461
+ name: 'test-tool-2',
462
+ displayName: 'Test Tool 2',
463
+ shouldConfirmExecute: vi.fn(mockToolConfirmationFn),
464
+ execute: vi
465
+ .fn()
466
+ .mockResolvedValue({ llmContent: 'tool 2 done', returnDisplay: '' }),
467
+ });
468
+
469
+ getToolRegistrySpy.mockReturnValue({
470
+ getAllTools: vi.fn().mockReturnValue([mockTool1, mockTool2]),
471
+ getToolsByServer: vi.fn().mockReturnValue([]),
472
+ getTool: vi.fn().mockImplementation((name: string) => {
473
+ if (name === 'test-tool-1') return mockTool1;
474
+ if (name === 'test-tool-2') return mockTool2;
475
+ return undefined;
476
+ }),
477
+ });
478
+
479
+ const agent = request.agent(app);
480
+ const res = await agent
481
+ .post('/')
482
+ .send(
483
+ createStreamMessageRequest(
484
+ 'run two tools',
485
+ 'a2a-multi-tool-test-message',
486
+ ),
487
+ )
488
+ .set('Content-Type', 'application/json')
489
+ .expect(200);
490
+
491
+ const events = streamToSSEEvents(res.text);
492
+ assertTaskCreationAndWorkingStatus(events);
493
+
494
+ // --- Assert the sequential execution flow ---
495
+ const eventStream = events.slice(2).map((e) => {
496
+ const update = e.result as TaskStatusUpdateEvent;
497
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
498
+ const agentData = update.metadata?.['coderAgent'] as any;
499
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
500
+ const toolData = update.status.message?.parts[0] as any;
501
+ if (!toolData) {
502
+ return { kind: agentData.kind };
503
+ }
504
+ return {
505
+ kind: agentData.kind,
506
+ status: toolData.data?.status,
507
+ callId: toolData.data?.request.callId,
508
+ };
509
+ });
510
+
511
+ const expectedFlow = [
512
+ // Initial state change
513
+ { kind: 'state-change', status: undefined, callId: undefined },
514
+ // Tool 1 Lifecycle
515
+ {
516
+ kind: 'tool-call-update',
517
+ status: 'scheduled',
518
+ callId: 'test-call-id-1',
519
+ },
520
+ {
521
+ kind: 'tool-call-update',
522
+ status: 'scheduled',
523
+ callId: 'test-call-id-1',
524
+ },
525
+ {
526
+ kind: 'tool-call-update',
527
+ status: 'executing',
528
+ callId: 'test-call-id-1',
529
+ },
530
+ {
531
+ kind: 'tool-call-update',
532
+ status: 'success',
533
+ callId: 'test-call-id-1',
534
+ },
535
+ // Tool 2 Lifecycle
536
+ {
537
+ kind: 'tool-call-update',
538
+ status: 'scheduled',
539
+ callId: 'test-call-id-2',
540
+ },
541
+ {
542
+ kind: 'tool-call-update',
543
+ status: 'scheduled',
544
+ callId: 'test-call-id-2',
545
+ },
546
+ {
547
+ kind: 'tool-call-update',
548
+ status: 'executing',
549
+ callId: 'test-call-id-2',
550
+ },
551
+ {
552
+ kind: 'tool-call-update',
553
+ status: 'success',
554
+ callId: 'test-call-id-2',
555
+ },
556
+ // Final updates
557
+ { kind: 'state-change', status: undefined, callId: undefined },
558
+ { kind: 'text-content', status: undefined, callId: undefined },
559
+ ];
560
+
561
+ // Use `toContainEqual` for flexibility if other events are interspersed.
562
+ expect(eventStream).toEqual(expect.arrayContaining(expectedFlow));
563
+
564
+ assertUniqueFinalEventIsLast(events);
565
+ });
566
+
567
+ it('should handle tool calls that do not require approval', async () => {
568
+ // First call yields the tool request
569
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
570
+ yield* [
571
+ {
572
+ type: GeminiEventType.ToolCallRequest,
573
+ value: {
574
+ callId: 'test-call-id-no-approval',
575
+ name: 'test-tool-no-approval',
576
+ args: {},
577
+ },
578
+ },
579
+ ];
580
+ });
581
+ // Second call, after the tool runs, yields the final text
582
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
583
+ yield* [{ type: 'content', value: 'Tool executed successfully.' }];
584
+ });
585
+
586
+ const mockTool = new MockTool({
587
+ name: 'test-tool-no-approval',
588
+ displayName: 'Test Tool No Approval',
589
+ execute: vi.fn().mockResolvedValue({
590
+ llmContent: 'Tool executed successfully.',
591
+ returnDisplay: 'Tool executed successfully.',
592
+ }),
593
+ });
594
+
595
+ getToolRegistrySpy.mockReturnValue({
596
+ getAllTools: vi.fn().mockReturnValue([mockTool]),
597
+ getToolsByServer: vi.fn().mockReturnValue([]),
598
+ getTool: vi.fn().mockReturnValue(mockTool),
599
+ });
600
+
601
+ const agent = request.agent(app);
602
+ const res = await agent
603
+ .post('/')
604
+ .send(
605
+ createStreamMessageRequest(
606
+ 'run a tool without approval',
607
+ 'a2a-no-approval-test-message',
608
+ ),
609
+ )
610
+ .set('Content-Type', 'application/json')
611
+ .expect(200);
612
+
613
+ const events = streamToSSEEvents(res.text);
614
+ assertTaskCreationAndWorkingStatus(events);
615
+
616
+ // Status update: working
617
+ const workingEvent2 = events[2].result as TaskStatusUpdateEvent;
618
+ expect(workingEvent2.kind).toBe('status-update');
619
+ expect(workingEvent2.status.state).toBe('working');
620
+
621
+ // Status update: tool-call-update (scheduled)
622
+ const scheduledEvent1 = events[3].result as TaskStatusUpdateEvent;
623
+ expect(scheduledEvent1.metadata?.['coderAgent']).toMatchObject({
624
+ kind: 'tool-call-update',
625
+ });
626
+ expect(scheduledEvent1.status.message?.parts).toMatchObject([
627
+ {
628
+ data: {
629
+ status: 'scheduled',
630
+ request: { callId: 'test-call-id-no-approval' },
631
+ },
632
+ },
633
+ ]);
634
+
635
+ // Status update: tool-call-update (scheduled)
636
+ const scheduledEvent2 = events[4].result as TaskStatusUpdateEvent;
637
+ expect(scheduledEvent2.metadata?.['coderAgent']).toMatchObject({
638
+ kind: 'tool-call-update',
639
+ });
640
+ expect(scheduledEvent2.status.message?.parts).toMatchObject([
641
+ {
642
+ data: {
643
+ status: 'scheduled',
644
+ request: { callId: 'test-call-id-no-approval' },
645
+ },
646
+ },
647
+ ]);
648
+
649
+ // Status update: tool-call-update (scheduled)
650
+ const scheduledEvent3 = events[5].result as TaskStatusUpdateEvent;
651
+ expect(scheduledEvent3.metadata?.['coderAgent']).toMatchObject({
652
+ kind: 'tool-call-update',
653
+ });
654
+ expect(scheduledEvent3.status.message?.parts).toMatchObject([
655
+ {
656
+ data: {
657
+ status: 'scheduled',
658
+ request: { callId: 'test-call-id-no-approval' },
659
+ },
660
+ },
661
+ ]);
662
+
663
+ // Status update: tool-call-update (executing)
664
+ const executingEvent = events[6].result as TaskStatusUpdateEvent;
665
+ expect(executingEvent.metadata?.['coderAgent']).toMatchObject({
666
+ kind: 'tool-call-update',
667
+ });
668
+ expect(executingEvent.status.message?.parts).toMatchObject([
669
+ {
670
+ data: {
671
+ status: 'executing',
672
+ request: { callId: 'test-call-id-no-approval' },
673
+ },
674
+ },
675
+ ]);
676
+
677
+ // Status update: tool-call-update (success)
678
+ const successEvent = events[7].result as TaskStatusUpdateEvent;
679
+ expect(successEvent.metadata?.['coderAgent']).toMatchObject({
680
+ kind: 'tool-call-update',
681
+ });
682
+ expect(successEvent.status.message?.parts).toMatchObject([
683
+ {
684
+ data: {
685
+ status: 'success',
686
+ request: { callId: 'test-call-id-no-approval' },
687
+ },
688
+ },
689
+ ]);
690
+
691
+ // Status update: working (before sending tool result to LLM)
692
+ const workingEvent3 = events[8].result as TaskStatusUpdateEvent;
693
+ expect(workingEvent3.kind).toBe('status-update');
694
+ expect(workingEvent3.status.state).toBe('working');
695
+
696
+ // Status update: text-content (final LLM response)
697
+ const textContentEvent = events[9].result as TaskStatusUpdateEvent;
698
+ expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({
699
+ kind: 'text-content',
700
+ });
701
+ expect(textContentEvent.status.message?.parts).toMatchObject([
702
+ { text: 'Tool executed successfully.' },
703
+ ]);
704
+
705
+ assertUniqueFinalEventIsLast(events);
706
+ expect(events.length).toBe(11);
707
+ });
708
+
709
+ it('should bypass tool approval in YOLO mode', async () => {
710
+ // First call yields the tool request
711
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
712
+ yield* [
713
+ {
714
+ type: GeminiEventType.ToolCallRequest,
715
+ value: {
716
+ callId: 'test-call-id-yolo',
717
+ name: 'test-tool-yolo',
718
+ args: {},
719
+ },
720
+ },
721
+ ];
722
+ });
723
+ // Second call, after the tool runs, yields the final text
724
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
725
+ yield* [{ type: 'content', value: 'Tool executed successfully.' }];
726
+ });
727
+
728
+ // Set approval mode to yolo
729
+ getApprovalModeSpy.mockReturnValue(ApprovalMode.YOLO);
730
+
731
+ const mockTool = new MockTool({
732
+ name: 'test-tool-yolo',
733
+ displayName: 'Test Tool YOLO',
734
+ execute: vi.fn().mockResolvedValue({
735
+ llmContent: 'Tool executed successfully.',
736
+ returnDisplay: 'Tool executed successfully.',
737
+ }),
738
+ });
739
+
740
+ getToolRegistrySpy.mockReturnValue({
741
+ getAllTools: vi.fn().mockReturnValue([mockTool]),
742
+ getToolsByServer: vi.fn().mockReturnValue([]),
743
+ getTool: vi.fn().mockReturnValue(mockTool),
744
+ });
745
+
746
+ const agent = request.agent(app);
747
+ const res = await agent
748
+ .post('/')
749
+ .send(
750
+ createStreamMessageRequest(
751
+ 'run a tool in yolo mode',
752
+ 'a2a-yolo-mode-test-message',
753
+ ),
754
+ )
755
+ .set('Content-Type', 'application/json')
756
+ .expect(200);
757
+
758
+ const events = streamToSSEEvents(res.text);
759
+ assertTaskCreationAndWorkingStatus(events);
760
+
761
+ // Status update: working
762
+ const workingEvent2 = events[2].result as TaskStatusUpdateEvent;
763
+ expect(workingEvent2.kind).toBe('status-update');
764
+ expect(workingEvent2.status.state).toBe('working');
765
+
766
+ // Status update: tool-call-update (scheduled)
767
+ const scheduledEvent = events[3].result as TaskStatusUpdateEvent;
768
+ expect(scheduledEvent.metadata?.['coderAgent']).toMatchObject({
769
+ kind: 'tool-call-update',
770
+ });
771
+ expect(scheduledEvent.status.message?.parts).toMatchObject([
772
+ {
773
+ data: {
774
+ status: 'scheduled',
775
+ request: { callId: 'test-call-id-yolo' },
776
+ },
777
+ },
778
+ ]);
779
+
780
+ // Status update: tool-call-update (scheduled)
781
+ const awaitingEvent = events[4].result as TaskStatusUpdateEvent;
782
+ expect(awaitingEvent.metadata?.['coderAgent']).toMatchObject({
783
+ kind: 'tool-call-update',
784
+ });
785
+ expect(awaitingEvent.status.message?.parts).toMatchObject([
786
+ {
787
+ data: {
788
+ status: 'scheduled',
789
+ request: { callId: 'test-call-id-yolo' },
790
+ },
791
+ },
792
+ ]);
793
+
794
+ // Status update: tool-call-update (scheduled)
795
+ const scheduledEvent3 = events[5].result as TaskStatusUpdateEvent;
796
+ expect(scheduledEvent3.metadata?.['coderAgent']).toMatchObject({
797
+ kind: 'tool-call-update',
798
+ });
799
+ expect(scheduledEvent3.status.message?.parts).toMatchObject([
800
+ {
801
+ data: {
802
+ status: 'scheduled',
803
+ request: { callId: 'test-call-id-yolo' },
804
+ },
805
+ },
806
+ ]);
807
+
808
+ // Status update: tool-call-update (executing)
809
+ const executingEvent = events[6].result as TaskStatusUpdateEvent;
810
+ expect(executingEvent.metadata?.['coderAgent']).toMatchObject({
811
+ kind: 'tool-call-update',
812
+ });
813
+ expect(executingEvent.status.message?.parts).toMatchObject([
814
+ {
815
+ data: {
816
+ status: 'executing',
817
+ request: { callId: 'test-call-id-yolo' },
818
+ },
819
+ },
820
+ ]);
821
+
822
+ // Status update: tool-call-update (success)
823
+ const successEvent = events[7].result as TaskStatusUpdateEvent;
824
+ expect(successEvent.metadata?.['coderAgent']).toMatchObject({
825
+ kind: 'tool-call-update',
826
+ });
827
+ expect(successEvent.status.message?.parts).toMatchObject([
828
+ {
829
+ data: {
830
+ status: 'success',
831
+ request: { callId: 'test-call-id-yolo' },
832
+ },
833
+ },
834
+ ]);
835
+
836
+ // Status update: working (before sending tool result to LLM)
837
+ const workingEvent3 = events[8].result as TaskStatusUpdateEvent;
838
+ expect(workingEvent3.kind).toBe('status-update');
839
+ expect(workingEvent3.status.state).toBe('working');
840
+
841
+ // Status update: text-content (final LLM response)
842
+ const textContentEvent = events[9].result as TaskStatusUpdateEvent;
843
+ expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({
844
+ kind: 'text-content',
845
+ });
846
+ expect(textContentEvent.status.message?.parts).toMatchObject([
847
+ { text: 'Tool executed successfully.' },
848
+ ]);
849
+
850
+ assertUniqueFinalEventIsLast(events);
851
+ expect(events.length).toBe(11);
852
+ });
853
+
854
+ it('should include traceId in status updates when available', async () => {
855
+ const traceId = 'test-trace-id';
856
+ sendMessageStreamSpy.mockImplementation(async function* () {
857
+ yield* [
858
+ { type: 'content', value: 'Hello', traceId },
859
+ { type: 'thought', value: { subject: 'Thinking...' }, traceId },
860
+ ];
861
+ });
862
+
863
+ const agent = request.agent(app);
864
+ const res = await agent
865
+ .post('/')
866
+ .send(createStreamMessageRequest('hello', 'a2a-trace-id-test'))
867
+ .set('Content-Type', 'application/json')
868
+ .expect(200);
869
+
870
+ const events = streamToSSEEvents(res.text);
871
+
872
+ // The first two events are task-creation and working status
873
+ const textContentEvent = events[2].result as TaskStatusUpdateEvent;
874
+ expect(textContentEvent.kind).toBe('status-update');
875
+ expect(textContentEvent.metadata?.['traceId']).toBe(traceId);
876
+
877
+ const thoughtEvent = events[3].result as TaskStatusUpdateEvent;
878
+ expect(thoughtEvent.kind).toBe('status-update');
879
+ expect(thoughtEvent.metadata?.['traceId']).toBe(traceId);
880
+ });
881
+
882
+ describe('/listCommands', () => {
883
+ it('should return a list of top-level commands', async () => {
884
+ const mockCommands = [
885
+ {
886
+ name: 'test-command',
887
+ description: 'A test command',
888
+ topLevel: true,
889
+ arguments: [{ name: 'arg1', description: 'Argument 1' }],
890
+ subCommands: [
891
+ {
892
+ name: 'sub-command',
893
+ description: 'A sub command',
894
+ topLevel: false,
895
+ execute: vi.fn(),
896
+ },
897
+ ],
898
+ execute: vi.fn(),
899
+ },
900
+ {
901
+ name: 'another-command',
902
+ description: 'Another test command',
903
+ topLevel: true,
904
+ execute: vi.fn(),
905
+ },
906
+ {
907
+ name: 'not-top-level',
908
+ description: 'Not a top level command',
909
+ topLevel: false,
910
+ execute: vi.fn(),
911
+ },
912
+ ];
913
+
914
+ const getAllCommandsSpy = vi
915
+ .spyOn(commandRegistry, 'getAllCommands')
916
+ .mockReturnValue(mockCommands);
917
+
918
+ const agent = request.agent(app);
919
+ const res = await agent.get('/listCommands').expect(200);
920
+
921
+ expect(res.body).toEqual({
922
+ commands: [
923
+ {
924
+ name: 'test-command',
925
+ description: 'A test command',
926
+ arguments: [{ name: 'arg1', description: 'Argument 1' }],
927
+ subCommands: [
928
+ {
929
+ name: 'sub-command',
930
+ description: 'A sub command',
931
+ arguments: [],
932
+ subCommands: [],
933
+ },
934
+ ],
935
+ },
936
+ {
937
+ name: 'another-command',
938
+ description: 'Another test command',
939
+ arguments: [],
940
+ subCommands: [],
941
+ },
942
+ ],
943
+ });
944
+
945
+ expect(getAllCommandsSpy).toHaveBeenCalledOnce();
946
+ getAllCommandsSpy.mockRestore();
947
+ });
948
+
949
+ it('should handle cyclic commands gracefully', async () => {
950
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
951
+
952
+ const cyclicCommand: Command = {
953
+ name: 'cyclic-command',
954
+ description: 'A cyclic command',
955
+ topLevel: true,
956
+ execute: vi.fn(),
957
+ subCommands: [],
958
+ };
959
+ cyclicCommand.subCommands?.push(cyclicCommand); // Create cycle
960
+
961
+ const getAllCommandsSpy = vi
962
+ .spyOn(commandRegistry, 'getAllCommands')
963
+ .mockReturnValue([cyclicCommand]);
964
+
965
+ const agent = request.agent(app);
966
+ const res = await agent.get('/listCommands').expect(200);
967
+
968
+ expect(res.body.commands[0].name).toBe('cyclic-command');
969
+ expect(res.body.commands[0].subCommands).toEqual([]);
970
+
971
+ expect(warnSpy).toHaveBeenCalledWith(
972
+ 'Command cyclic-command already inserted in the response, skipping',
973
+ );
974
+
975
+ getAllCommandsSpy.mockRestore();
976
+ warnSpy.mockRestore();
977
+ });
978
+ });
979
+
980
+ describe('/executeCommand', () => {
981
+ const mockExtensions = [{ name: 'test-extension', version: '0.0.1' }];
982
+
983
+ beforeEach(() => {
984
+ getExtensionsSpy.mockReturnValue(mockExtensions);
985
+ });
986
+
987
+ afterEach(() => {
988
+ getExtensionsSpy.mockClear();
989
+ });
990
+
991
+ it('should return extensions for valid command', async () => {
992
+ const mockExtensionsCommand = {
993
+ name: 'extensions list',
994
+ description: 'a mock command',
995
+ execute: vi.fn(async (context: CommandContext) => {
996
+ // Simulate the actual command's behavior
997
+ const extensions = context.config.getExtensions();
998
+ return { name: 'extensions list', data: extensions };
999
+ }),
1000
+ };
1001
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockExtensionsCommand);
1002
+
1003
+ const agent = request.agent(app);
1004
+ const res = await agent
1005
+ .post('/executeCommand')
1006
+ .send({ command: 'extensions list', args: [] })
1007
+ .set('Content-Type', 'application/json')
1008
+ .expect(200);
1009
+
1010
+ expect(res.body).toEqual({
1011
+ name: 'extensions list',
1012
+ data: mockExtensions,
1013
+ });
1014
+ expect(getExtensionsSpy).toHaveBeenCalled();
1015
+ });
1016
+
1017
+ it('should return 404 for invalid command', async () => {
1018
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(undefined);
1019
+
1020
+ const agent = request.agent(app);
1021
+ const res = await agent
1022
+ .post('/executeCommand')
1023
+ .send({ command: 'invalid command' })
1024
+ .set('Content-Type', 'application/json')
1025
+ .expect(404);
1026
+
1027
+ expect(res.body.error).toBe('Command not found: invalid command');
1028
+ expect(getExtensionsSpy).not.toHaveBeenCalled();
1029
+ });
1030
+
1031
+ it('should return 400 for missing command', async () => {
1032
+ const agent = request.agent(app);
1033
+ await agent
1034
+ .post('/executeCommand')
1035
+ .send({ args: [] })
1036
+ .set('Content-Type', 'application/json')
1037
+ .expect(400);
1038
+ expect(getExtensionsSpy).not.toHaveBeenCalled();
1039
+ });
1040
+
1041
+ it('should return 400 if args is not an array', async () => {
1042
+ const agent = request.agent(app);
1043
+ const res = await agent
1044
+ .post('/executeCommand')
1045
+ .send({ command: 'extensions.list', args: 'not-an-array' })
1046
+ .set('Content-Type', 'application/json')
1047
+ .expect(400);
1048
+
1049
+ expect(res.body.error).toBe('"args" field must be an array.');
1050
+ expect(getExtensionsSpy).not.toHaveBeenCalled();
1051
+ });
1052
+
1053
+ it('should execute a command that does not require a workspace when CODER_AGENT_WORKSPACE_PATH is not set', async () => {
1054
+ const mockCommand = {
1055
+ name: 'test-command',
1056
+ description: 'a mock command',
1057
+ execute: vi
1058
+ .fn()
1059
+ .mockResolvedValue({ name: 'test-command', data: 'success' }),
1060
+ };
1061
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockCommand);
1062
+
1063
+ vi.stubEnv('CODER_AGENT_WORKSPACE_PATH', '');
1064
+ const response = await request(app)
1065
+ .post('/executeCommand')
1066
+ .send({ command: 'test-command', args: [] });
1067
+
1068
+ expect(response.status).toBe(200);
1069
+ expect(response.body.data).toBe('success');
1070
+ });
1071
+
1072
+ it('should return 400 for a command that requires a workspace when CODER_AGENT_WORKSPACE_PATH is not set', async () => {
1073
+ const mockWorkspaceCommand = {
1074
+ name: 'workspace-command',
1075
+ description: 'A command that requires a workspace',
1076
+ requiresWorkspace: true,
1077
+ execute: vi
1078
+ .fn()
1079
+ .mockResolvedValue({ name: 'workspace-command', data: 'success' }),
1080
+ };
1081
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockWorkspaceCommand);
1082
+
1083
+ vi.stubEnv('CODER_AGENT_WORKSPACE_PATH', '');
1084
+ const response = await request(app)
1085
+ .post('/executeCommand')
1086
+ .send({ command: 'workspace-command', args: [] });
1087
+
1088
+ expect(response.status).toBe(400);
1089
+ expect(response.body.error).toBe(
1090
+ 'Command "workspace-command" requires a workspace, but CODER_AGENT_WORKSPACE_PATH is not set.',
1091
+ );
1092
+ });
1093
+
1094
+ it('should execute a command that requires a workspace when CODER_AGENT_WORKSPACE_PATH is set', async () => {
1095
+ const mockWorkspaceCommand = {
1096
+ name: 'workspace-command',
1097
+ description: 'A command that requires a workspace',
1098
+ requiresWorkspace: true,
1099
+ execute: vi
1100
+ .fn()
1101
+ .mockResolvedValue({ name: 'workspace-command', data: 'success' }),
1102
+ };
1103
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockWorkspaceCommand);
1104
+
1105
+ vi.stubEnv('CODER_AGENT_WORKSPACE_PATH', '/tmp/test-workspace');
1106
+ const response = await request(app)
1107
+ .post('/executeCommand')
1108
+ .send({ command: 'workspace-command', args: [] });
1109
+
1110
+ expect(response.status).toBe(200);
1111
+ expect(response.body.data).toBe('success');
1112
+ });
1113
+
1114
+ it('should include agentExecutor in context', async () => {
1115
+ const mockCommand = {
1116
+ name: 'context-check-command',
1117
+ description: 'checks context',
1118
+ execute: vi.fn(async (context: CommandContext) => {
1119
+ if (!context.agentExecutor) {
1120
+ throw new Error('agentExecutor missing');
1121
+ }
1122
+ return { name: 'context-check-command', data: 'success' };
1123
+ }),
1124
+ };
1125
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockCommand);
1126
+
1127
+ const agent = request.agent(app);
1128
+ const res = await agent
1129
+ .post('/executeCommand')
1130
+ .send({ command: 'context-check-command', args: [] })
1131
+ .set('Content-Type', 'application/json')
1132
+ .expect(200);
1133
+
1134
+ expect(res.body.data).toBe('success');
1135
+ });
1136
+
1137
+ describe('/executeCommand streaming', () => {
1138
+ it('should execute a streaming command and stream back events', (done: (
1139
+ err?: unknown,
1140
+ ) => void) => {
1141
+ const executeSpy = vi.fn(async (context: CommandContext) => {
1142
+ context.eventBus?.publish({
1143
+ kind: 'status-update',
1144
+ status: { state: 'working' },
1145
+ taskId: 'test-task',
1146
+ contextId: 'test-context',
1147
+ final: false,
1148
+ });
1149
+ context.eventBus?.publish({
1150
+ kind: 'status-update',
1151
+ status: { state: 'completed' },
1152
+ taskId: 'test-task',
1153
+ contextId: 'test-context',
1154
+ final: true,
1155
+ });
1156
+ return { name: 'stream-test', data: 'done' };
1157
+ });
1158
+
1159
+ const mockStreamCommand = {
1160
+ name: 'stream-test',
1161
+ description: 'A test streaming command',
1162
+ streaming: true,
1163
+ execute: executeSpy,
1164
+ };
1165
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockStreamCommand);
1166
+
1167
+ const agent = request.agent(app);
1168
+ agent
1169
+ .post('/executeCommand')
1170
+ .send({ command: 'stream-test', args: [] })
1171
+ .set('Content-Type', 'application/json')
1172
+ .set('Accept', 'text/event-stream')
1173
+ .on('response', (res) => {
1174
+ let data = '';
1175
+ res.on('data', (chunk: Buffer) => {
1176
+ data += chunk.toString();
1177
+ });
1178
+ res.on('end', () => {
1179
+ try {
1180
+ const events = streamToSSEEvents(data);
1181
+ expect(events.length).toBe(2);
1182
+ expect(events[0].result).toEqual({
1183
+ kind: 'status-update',
1184
+ status: { state: 'working' },
1185
+ taskId: 'test-task',
1186
+ contextId: 'test-context',
1187
+ final: false,
1188
+ });
1189
+ expect(events[1].result).toEqual({
1190
+ kind: 'status-update',
1191
+ status: { state: 'completed' },
1192
+ taskId: 'test-task',
1193
+ contextId: 'test-context',
1194
+ final: true,
1195
+ });
1196
+ expect(executeSpy).toHaveBeenCalled();
1197
+ done();
1198
+ } catch (e) {
1199
+ done(e);
1200
+ }
1201
+ });
1202
+ })
1203
+ .end();
1204
+ });
1205
+
1206
+ it('should handle non-streaming commands gracefully', async () => {
1207
+ const mockNonStreamCommand = {
1208
+ name: 'non-stream-test',
1209
+ description: 'A test non-streaming command',
1210
+ execute: vi
1211
+ .fn()
1212
+ .mockResolvedValue({ name: 'non-stream-test', data: 'done' }),
1213
+ };
1214
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockNonStreamCommand);
1215
+
1216
+ const agent = request.agent(app);
1217
+ const res = await agent
1218
+ .post('/executeCommand')
1219
+ .send({ command: 'non-stream-test', args: [] })
1220
+ .set('Content-Type', 'application/json')
1221
+ .expect(200);
1222
+
1223
+ expect(res.body).toEqual({ name: 'non-stream-test', data: 'done' });
1224
+ });
1225
+ });
1226
+ });
1227
+
1228
+ describe('main', () => {
1229
+ it('should listen on localhost only', async () => {
1230
+ const listenSpy = vi
1231
+ .spyOn(express.application, 'listen')
1232
+ .mockImplementation((...args: unknown[]) => {
1233
+ // Trigger the callback passed to listen
1234
+ const callback = args.find(
1235
+ (arg): arg is () => void => typeof arg === 'function',
1236
+ );
1237
+ if (callback) {
1238
+ callback();
1239
+ }
1240
+
1241
+ return {
1242
+ address: () => ({ port: 1234 }),
1243
+ on: vi.fn(),
1244
+ once: vi.fn(),
1245
+ emit: vi.fn(),
1246
+ } as unknown as Server;
1247
+ });
1248
+
1249
+ // Avoid process.exit if possible, or mock it if main might fail
1250
+ const exitSpy = vi
1251
+ .spyOn(process, 'exit')
1252
+ .mockImplementation(() => undefined as never);
1253
+
1254
+ await main();
1255
+
1256
+ expect(listenSpy).toHaveBeenCalledWith(
1257
+ expect.any(Number),
1258
+ 'localhost',
1259
+ expect.any(Function),
1260
+ );
1261
+
1262
+ listenSpy.mockRestore();
1263
+ exitSpy.mockRestore();
1264
+ });
1265
+ });
1266
+ });
packages/a2a-server/src/http/app.ts ADDED
@@ -0,0 +1,435 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import express, { type Request } from 'express';
8
+
9
+ import type { AgentCard, Message } from '@a2a-js/sdk';
10
+ import {
11
+ type TaskStore,
12
+ DefaultRequestHandler,
13
+ InMemoryTaskStore,
14
+ DefaultExecutionEventBus,
15
+ type AgentExecutionEvent,
16
+ UnauthenticatedUser,
17
+ } from '@a2a-js/sdk/server';
18
+ import { A2AExpressApp, type UserBuilder } from '@a2a-js/sdk/server/express'; // Import server components
19
+ import { v4 as uuidv4 } from 'uuid';
20
+ import { logger } from '../utils/logger.js';
21
+ import type { AgentSettings } from '../types.js';
22
+ import { GCSTaskStore, NoOpTaskStore } from '../persistence/gcs.js';
23
+ import { CoderAgentExecutor } from '../agent/executor.js';
24
+ import { requestStorage } from './requestStorage.js';
25
+ import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js';
26
+ import { loadSettings } from '../config/settings.js';
27
+ import { loadExtensions } from '../config/extension.js';
28
+ import { commandRegistry } from '../commands/command-registry.js';
29
+ import {
30
+ debugLogger,
31
+ SimpleExtensionLoader,
32
+ GitService,
33
+ checkPathTrust,
34
+ isHeadlessMode,
35
+ } from '@google/gemini-cli-core';
36
+ import type { Command, CommandArgument } from '../commands/types.js';
37
+
38
+ type CommandResponse = {
39
+ name: string;
40
+ description: string;
41
+ arguments: CommandArgument[];
42
+ subCommands: CommandResponse[];
43
+ };
44
+
45
+ const coderAgentCard: AgentCard = {
46
+ name: 'Gemini SDLC Agent',
47
+ description:
48
+ 'An agent that generates code based on natural language instructions and streams file outputs.',
49
+ url: 'http://localhost:41242/',
50
+ provider: {
51
+ organization: 'Google',
52
+ url: 'https://google.com',
53
+ },
54
+ protocolVersion: '0.3.0',
55
+ version: '0.0.2', // Incremented version
56
+ capabilities: {
57
+ streaming: true,
58
+ pushNotifications: false,
59
+ stateTransitionHistory: true,
60
+ },
61
+ securitySchemes: {
62
+ bearerAuth: {
63
+ type: 'http',
64
+ scheme: 'bearer',
65
+ },
66
+ basicAuth: {
67
+ type: 'http',
68
+ scheme: 'basic',
69
+ },
70
+ },
71
+ security: [{ bearerAuth: [] }, { basicAuth: [] }],
72
+ defaultInputModes: ['text'],
73
+ defaultOutputModes: ['text'],
74
+ skills: [
75
+ {
76
+ id: 'code_generation',
77
+ name: 'Code Generation',
78
+ description:
79
+ 'Generates code snippets or complete files based on user requests, streaming the results.',
80
+ tags: ['code', 'development', 'programming'],
81
+ examples: [
82
+ 'Write a python function to calculate fibonacci numbers.',
83
+ 'Create an HTML file with a basic button that alerts "Hello!" when clicked.',
84
+ ],
85
+ inputModes: ['text'],
86
+ outputModes: ['text'],
87
+ },
88
+ ],
89
+ supportsAuthenticatedExtendedCard: false,
90
+ };
91
+
92
+ export function updateCoderAgentCardUrl(port: number) {
93
+ coderAgentCard.url = `http://localhost:${port}/`;
94
+ }
95
+
96
+ const customUserBuilder: UserBuilder = async (req: Request) => {
97
+ const auth = req.headers['authorization'];
98
+ if (auth) {
99
+ const scheme = auth.split(' ')[0];
100
+ logger.info(
101
+ `[customUserBuilder] Received Authorization header with scheme: ${scheme}`,
102
+ );
103
+ }
104
+ if (!auth) return new UnauthenticatedUser();
105
+
106
+ // 1. Bearer Auth
107
+ if (auth.startsWith('Bearer ')) {
108
+ const token = auth.substring(7);
109
+ if (token === 'valid-token') {
110
+ return { userName: 'bearer-user', isAuthenticated: true };
111
+ }
112
+ }
113
+
114
+ // 2. Basic Auth
115
+ if (auth.startsWith('Basic ')) {
116
+ const credentials = Buffer.from(auth.substring(6), 'base64').toString();
117
+ if (credentials === 'admin:password') {
118
+ return { userName: 'basic-user', isAuthenticated: true };
119
+ }
120
+ }
121
+
122
+ return new UnauthenticatedUser();
123
+ };
124
+
125
+ async function handleExecuteCommand(
126
+ req: express.Request,
127
+ res: express.Response,
128
+ context: {
129
+ config: Awaited<ReturnType<typeof loadConfig>>;
130
+ git: GitService | undefined;
131
+ agentExecutor: CoderAgentExecutor;
132
+ },
133
+ ) {
134
+ logger.info('[CoreAgent] Received /executeCommand request: ', req.body);
135
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
136
+ const { command, args } = req.body;
137
+ try {
138
+ if (typeof command !== 'string') {
139
+ return res.status(400).json({ error: 'Invalid "command" field.' });
140
+ }
141
+
142
+ if (args && !Array.isArray(args)) {
143
+ return res.status(400).json({ error: '"args" field must be an array.' });
144
+ }
145
+
146
+ const commandToExecute = commandRegistry.get(command);
147
+
148
+ if (commandToExecute?.requiresWorkspace) {
149
+ if (!process.env['CODER_AGENT_WORKSPACE_PATH']) {
150
+ return res.status(400).json({
151
+ error: `Command "${command}" requires a workspace, but CODER_AGENT_WORKSPACE_PATH is not set.`,
152
+ });
153
+ }
154
+ }
155
+
156
+ if (!commandToExecute) {
157
+ return res.status(404).json({ error: `Command not found: ${command}` });
158
+ }
159
+
160
+ if (commandToExecute.streaming) {
161
+ const eventBus = new DefaultExecutionEventBus();
162
+ res.setHeader('Content-Type', 'text/event-stream');
163
+ const eventHandler = (event: AgentExecutionEvent) => {
164
+ const jsonRpcResponse = {
165
+ jsonrpc: '2.0',
166
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
167
+ id: 'taskId' in event ? event.taskId : (event as Message).messageId,
168
+ result: event,
169
+ };
170
+ res.write(`data: ${JSON.stringify(jsonRpcResponse)}\n`);
171
+ };
172
+ eventBus.on('event', eventHandler);
173
+
174
+ await commandToExecute.execute({ ...context, eventBus }, args ?? []);
175
+
176
+ eventBus.off('event', eventHandler);
177
+ eventBus.finished();
178
+ return res.end(); // Explicit return for streaming path
179
+ } else {
180
+ const result = await commandToExecute.execute(context, args ?? []);
181
+ logger.info('[CoreAgent] Sending /executeCommand response: ', result);
182
+ return res.status(200).json(result);
183
+ }
184
+ } catch (e) {
185
+ logger.error(
186
+ `Error executing /executeCommand: ${command} with args: ${JSON.stringify(
187
+ args,
188
+ )}`,
189
+ e,
190
+ );
191
+ const errorMessage =
192
+ e instanceof Error ? e.message : 'Unknown error executing command';
193
+ return res.status(500).json({ error: errorMessage });
194
+ }
195
+ }
196
+
197
+ export async function createApp() {
198
+ try {
199
+ // Load the server configuration once on startup.
200
+ const workspaceRoot = await setTargetDir(undefined);
201
+
202
+ // Use a temporary settings load to check if folder trust is enabled.
203
+ // This is similar to how the CLI handles the initial trust check.
204
+ const initialSettings = loadSettings(workspaceRoot, false);
205
+ const { isTrusted } = checkPathTrust({
206
+ path: workspaceRoot,
207
+ isFolderTrustEnabled: initialSettings.folderTrust ?? true,
208
+ isHeadless: isHeadlessMode(),
209
+ });
210
+
211
+ // Change the global working directory to the workspace root during startup
212
+ process.chdir(workspaceRoot);
213
+
214
+ // Load environment globally for the server startup
215
+ const globalEnv = await loadEnvironment(isTrusted ?? false, workspaceRoot);
216
+ // Only assign safe server-config variables to process.env to prevent credential leakage
217
+ const allowedServerKeys = [
218
+ 'CODER_AGENT_PORT',
219
+ 'CODER_AGENT_WORKSPACE_PATH',
220
+ 'GCS_BUCKET_NAME',
221
+ 'LOG_LEVEL',
222
+ 'GOOGLE_APPLICATION_CREDENTIALS',
223
+ 'GOOGLE_CLOUD_PROJECT',
224
+ 'GEMINI_CLI_USE_COMPUTE_ADC',
225
+ ];
226
+ for (const key of allowedServerKeys) {
227
+ if (globalEnv[key] !== undefined) {
228
+ process.env[key] = globalEnv[key];
229
+ }
230
+ }
231
+
232
+ const settings = loadSettings(workspaceRoot, isTrusted ?? false);
233
+ const extensions = loadExtensions(workspaceRoot, isTrusted ?? false);
234
+ const config = await loadConfig(
235
+ settings,
236
+ new SimpleExtensionLoader(extensions),
237
+ 'a2a-server',
238
+ isTrusted ?? false,
239
+ workspaceRoot,
240
+ );
241
+
242
+ let git: GitService | undefined;
243
+ if (config.getCheckpointingEnabled()) {
244
+ git = new GitService(config.getTargetDir(), config.storage);
245
+ await git.initialize();
246
+ }
247
+
248
+ // loadEnvironment() is called within getConfig now
249
+ const bucketName = process.env['GCS_BUCKET_NAME'];
250
+ let taskStoreForExecutor: TaskStore;
251
+ let taskStoreForHandler: TaskStore;
252
+
253
+ if (bucketName) {
254
+ logger.info(`Using GCSTaskStore with bucket: ${bucketName}`);
255
+ const gcsTaskStore = new GCSTaskStore(bucketName);
256
+ taskStoreForExecutor = gcsTaskStore;
257
+ taskStoreForHandler = new NoOpTaskStore(gcsTaskStore);
258
+ } else {
259
+ logger.info('Using InMemoryTaskStore');
260
+ const inMemoryTaskStore = new InMemoryTaskStore();
261
+ taskStoreForExecutor = inMemoryTaskStore;
262
+ taskStoreForHandler = inMemoryTaskStore;
263
+ }
264
+
265
+ const agentExecutor = new CoderAgentExecutor(taskStoreForExecutor);
266
+
267
+ const context = { config, git, agentExecutor };
268
+
269
+ const requestHandler = new DefaultRequestHandler(
270
+ coderAgentCard,
271
+ taskStoreForHandler,
272
+ agentExecutor,
273
+ );
274
+
275
+ let expressApp = express();
276
+ expressApp.use((req, res, next) => {
277
+ requestStorage.run({ req }, next);
278
+ });
279
+
280
+ const appBuilder = new A2AExpressApp(requestHandler, customUserBuilder);
281
+ expressApp = appBuilder.setupRoutes(expressApp, '');
282
+ expressApp.use(express.json());
283
+
284
+ expressApp.post('/tasks', async (req, res) => {
285
+ try {
286
+ const taskId = uuidv4();
287
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
288
+ const agentSettings = req.body.agentSettings as
289
+ | AgentSettings
290
+ | undefined;
291
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
292
+ const contextId = req.body.contextId || uuidv4();
293
+ const wrapper = await agentExecutor.createTask(
294
+ taskId,
295
+ contextId,
296
+ agentSettings,
297
+ );
298
+ await taskStoreForExecutor.save(wrapper.toSDKTask());
299
+ res.status(201).json(wrapper.id);
300
+ } catch (error) {
301
+ logger.error('[CoreAgent] Error creating task:', error);
302
+ const errorMessage =
303
+ error instanceof Error
304
+ ? error.message
305
+ : 'Unknown error creating task';
306
+ res.status(500).send({ error: errorMessage });
307
+ }
308
+ });
309
+
310
+ expressApp.post('/executeCommand', (req, res) => {
311
+ void handleExecuteCommand(req, res, context);
312
+ });
313
+
314
+ expressApp.get('/listCommands', (req, res) => {
315
+ try {
316
+ const transformCommand = (
317
+ command: Command,
318
+ visited: string[],
319
+ ): CommandResponse | undefined => {
320
+ const commandName = command.name;
321
+ if (visited.includes(commandName)) {
322
+ debugLogger.warn(
323
+ `Command ${commandName} already inserted in the response, skipping`,
324
+ );
325
+ return undefined;
326
+ }
327
+
328
+ return {
329
+ name: command.name,
330
+ description: command.description,
331
+ arguments: command.arguments ?? [],
332
+ subCommands: (command.subCommands ?? [])
333
+ .map((subCommand) =>
334
+ transformCommand(subCommand, visited.concat(commandName)),
335
+ )
336
+ .filter(
337
+ (subCommand): subCommand is CommandResponse => !!subCommand,
338
+ ),
339
+ };
340
+ };
341
+
342
+ const commands = commandRegistry
343
+ .getAllCommands()
344
+ .filter((command) => command.topLevel)
345
+ .map((command) => transformCommand(command, []));
346
+
347
+ return res.status(200).json({ commands });
348
+ } catch (e) {
349
+ logger.error('Error executing /listCommands:', e);
350
+ const errorMessage =
351
+ e instanceof Error ? e.message : 'Unknown error listing commands';
352
+ return res.status(500).json({ error: errorMessage });
353
+ }
354
+ });
355
+
356
+ expressApp.get('/tasks/metadata', async (req, res) => {
357
+ // This endpoint is only meaningful if the task store is in-memory.
358
+ if (!(taskStoreForExecutor instanceof InMemoryTaskStore)) {
359
+ res.status(501).send({
360
+ error:
361
+ 'Listing all task metadata is only supported when using InMemoryTaskStore.',
362
+ });
363
+ return;
364
+ }
365
+ try {
366
+ const wrappers = agentExecutor.getAllTasks();
367
+ if (wrappers && wrappers.length > 0) {
368
+ const tasksMetadata = await Promise.all(
369
+ wrappers.map((wrapper) => wrapper.task.getMetadata()),
370
+ );
371
+ res.status(200).json(tasksMetadata);
372
+ } else {
373
+ res.status(204).send();
374
+ }
375
+ } catch (error) {
376
+ logger.error('[CoreAgent] Error getting all task metadata:', error);
377
+ const errorMessage =
378
+ error instanceof Error
379
+ ? error.message
380
+ : 'Unknown error getting task metadata';
381
+ res.status(500).send({ error: errorMessage });
382
+ }
383
+ });
384
+
385
+ expressApp.get('/tasks/:taskId/metadata', async (req, res) => {
386
+ const taskId = req.params.taskId;
387
+ let wrapper = agentExecutor.getTask(taskId);
388
+ if (!wrapper) {
389
+ const sdkTask = await taskStoreForExecutor.load(taskId);
390
+ if (sdkTask) {
391
+ wrapper = await agentExecutor.reconstruct(sdkTask);
392
+ }
393
+ }
394
+ if (!wrapper) {
395
+ res.status(404).send({ error: 'Task not found' });
396
+ return;
397
+ }
398
+ res.json({ metadata: await wrapper.task.getMetadata() });
399
+ });
400
+ return expressApp;
401
+ } catch (error) {
402
+ logger.error('[CoreAgent] Error during startup:', error);
403
+ process.exit(1);
404
+ }
405
+ }
406
+
407
+ export async function main() {
408
+ try {
409
+ const expressApp = await createApp();
410
+ const port = Number(process.env['CODER_AGENT_PORT'] || 0);
411
+
412
+ const server = expressApp.listen(port, 'localhost', () => {
413
+ const address = server.address();
414
+ let actualPort;
415
+ if (process.env['CODER_AGENT_PORT']) {
416
+ actualPort = process.env['CODER_AGENT_PORT'];
417
+ } else if (address && typeof address !== 'string') {
418
+ actualPort = address.port;
419
+ } else {
420
+ throw new Error('[Core Agent] Could not find port number.');
421
+ }
422
+ updateCoderAgentCardUrl(Number(actualPort));
423
+ logger.info(
424
+ `[CoreAgent] Agent Server started on http://localhost:${actualPort}`,
425
+ );
426
+ logger.info(
427
+ `[CoreAgent] Agent Card: http://localhost:${actualPort}/.well-known/agent-card.json`,
428
+ );
429
+ logger.info('[CoreAgent] Press Ctrl+C to stop the server');
430
+ });
431
+ } catch (error) {
432
+ logger.error('[CoreAgent] Error during startup:', error);
433
+ process.exit(1);
434
+ }
435
+ }
packages/a2a-server/src/http/endpoints.test.ts ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ describe,
9
+ it,
10
+ expect,
11
+ beforeAll,
12
+ afterAll,
13
+ beforeEach,
14
+ afterEach,
15
+ vi,
16
+ } from 'vitest';
17
+ import request from 'supertest';
18
+ import type express from 'express';
19
+ import * as fs from 'node:fs';
20
+ import * as path from 'node:path';
21
+ import * as os from 'node:os';
22
+ import type { Server } from 'node:http';
23
+ import type { AddressInfo } from 'node:net';
24
+
25
+ import { createApp, updateCoderAgentCardUrl } from './app.js';
26
+ import type { TaskMetadata } from '../types.js';
27
+ import { createMockConfig } from '../utils/testing_utils.js';
28
+ import { debugLogger, type Config } from '@google/gemini-cli-core';
29
+ import { logger } from '../utils/logger.js';
30
+
31
+ // Mock the logger to avoid polluting test output
32
+ // Comment out to help debug
33
+ vi.mock('../utils/logger.js', () => ({
34
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
35
+ }));
36
+
37
+ vi.mock('../persistence/gcs.js', () => {
38
+ class MockGCSTaskStore {
39
+ load = vi.fn();
40
+ save = vi.fn();
41
+ }
42
+ class MockNoOpTaskStore {
43
+ constructor(public store: unknown) {}
44
+ }
45
+ return {
46
+ GCSTaskStore: MockGCSTaskStore,
47
+ NoOpTaskStore: MockNoOpTaskStore,
48
+ };
49
+ });
50
+
51
+ // Mock Task.create to avoid its complex setup
52
+ vi.mock('../agent/task.js', () => {
53
+ class MockTask {
54
+ id: string;
55
+ contextId: string;
56
+ taskState = 'submitted';
57
+ config = {
58
+ getContentGeneratorConfig: vi
59
+ .fn()
60
+ .mockReturnValue({ model: 'gemini-pro' }),
61
+ };
62
+ geminiClient = {
63
+ initialize: vi.fn().mockResolvedValue(undefined),
64
+ };
65
+ constructor(id: string, contextId: string) {
66
+ this.id = id;
67
+ this.contextId = contextId;
68
+ }
69
+ static create = vi
70
+ .fn()
71
+ .mockImplementation((id, contextId) =>
72
+ Promise.resolve(new MockTask(id, contextId)),
73
+ );
74
+ getMetadata = vi.fn().mockImplementation(async () => ({
75
+ id: this.id,
76
+ contextId: this.contextId,
77
+ taskState: this.taskState,
78
+ model: 'gemini-pro',
79
+ mcpServers: [],
80
+ availableTools: [],
81
+ }));
82
+ }
83
+ return { Task: MockTask };
84
+ });
85
+
86
+ vi.mock('../config/config.js', async () => {
87
+ const actual = await vi.importActual('../config/config.js');
88
+ return {
89
+ ...actual,
90
+ loadConfig: vi
91
+ .fn()
92
+ .mockImplementation(async () => createMockConfig({}) as Config),
93
+ };
94
+ });
95
+
96
+ describe('Agent Server Endpoints', () => {
97
+ let app: express.Express;
98
+ let server: Server;
99
+ let testWorkspace: string;
100
+
101
+ const createTask = (contextId: string) =>
102
+ request(app)
103
+ .post('/tasks')
104
+ .send({
105
+ contextId,
106
+ agentSettings: {
107
+ kind: 'agent-settings',
108
+ workspacePath: testWorkspace,
109
+ },
110
+ })
111
+ .set('Content-Type', 'application/json');
112
+
113
+ beforeAll(async () => {
114
+ // Create a unique temporary directory for the workspace to avoid conflicts
115
+ testWorkspace = fs.mkdtempSync(
116
+ path.join(os.tmpdir(), 'gemini-agent-test-'),
117
+ );
118
+ app = await createApp();
119
+ await new Promise<void>((resolve) => {
120
+ server = app.listen(0, () => {
121
+ const port = (server.address() as AddressInfo).port;
122
+ updateCoderAgentCardUrl(port);
123
+ resolve();
124
+ });
125
+ });
126
+ });
127
+
128
+ afterAll(async () => {
129
+ if (server) {
130
+ await new Promise<void>((resolve, reject) => {
131
+ server.close((err) => {
132
+ if (err) return reject(err);
133
+ resolve();
134
+ });
135
+ });
136
+ }
137
+
138
+ if (testWorkspace) {
139
+ try {
140
+ fs.rmSync(testWorkspace, { recursive: true, force: true });
141
+ } catch (e) {
142
+ debugLogger.warn(`Could not remove temp dir '${testWorkspace}':`, e);
143
+ }
144
+ }
145
+ });
146
+
147
+ it('should create a new task via POST /tasks', async () => {
148
+ const response = await createTask('test-context');
149
+ expect(response.status).toBe(201);
150
+ expect(response.body).toBeTypeOf('string'); // Should return the task ID
151
+ }, 7000);
152
+
153
+ it('should get metadata for a specific task via GET /tasks/:taskId/metadata', async () => {
154
+ const createResponse = await createTask('test-context-2');
155
+ const taskId = createResponse.body;
156
+ const response = await request(app).get(`/tasks/${taskId}/metadata`);
157
+ expect(response.status).toBe(200);
158
+ expect(response.body.metadata.id).toBe(taskId);
159
+ }, 6000);
160
+
161
+ it('should get metadata for all tasks via GET /tasks/metadata', async () => {
162
+ const createResponse = await createTask('test-context-3');
163
+ const taskId = createResponse.body;
164
+ const response = await request(app).get('/tasks/metadata');
165
+ expect(response.status).toBe(200);
166
+ expect(Array.isArray(response.body)).toBe(true);
167
+ expect(response.body.length).toBeGreaterThan(0);
168
+ const taskMetadata = response.body.find(
169
+ (m: TaskMetadata) => m.id === taskId,
170
+ );
171
+ expect(taskMetadata).toBeDefined();
172
+ });
173
+
174
+ it('should return 404 for a non-existent task', async () => {
175
+ const response = await request(app).get('/tasks/fake-task/metadata');
176
+ expect(response.status).toBe(404);
177
+ });
178
+
179
+ it('should return agent metadata via GET /.well-known/agent-card.json', async () => {
180
+ const response = await request(app).get('/.well-known/agent-card.json');
181
+ const port = (server.address() as AddressInfo).port;
182
+ expect(response.status).toBe(200);
183
+ expect(response.body.name).toBe('Gemini SDLC Agent');
184
+ expect(response.body.url).toBe(`http://localhost:${port}/`);
185
+ });
186
+
187
+ describe('GET /tasks/metadata with non-InMemoryTaskStore', () => {
188
+ let nonInMemoryApp: express.Express;
189
+ let nonInMemoryServer: Server;
190
+ let nonInMemoryWorkspace: string;
191
+ let originalCwd: string;
192
+
193
+ beforeEach(async () => {
194
+ originalCwd = process.cwd();
195
+ nonInMemoryWorkspace = fs.mkdtempSync(
196
+ path.join(os.tmpdir(), 'gemini-agent-gcs-test-'),
197
+ );
198
+ vi.stubEnv('CODER_AGENT_WORKSPACE_PATH', nonInMemoryWorkspace);
199
+ vi.stubEnv('GCS_BUCKET_NAME', 'test-bucket');
200
+ nonInMemoryApp = await createApp();
201
+ await new Promise<void>((resolve) => {
202
+ nonInMemoryServer = nonInMemoryApp.listen(0, () => {
203
+ resolve();
204
+ });
205
+ });
206
+ });
207
+
208
+ afterEach(async () => {
209
+ vi.unstubAllEnvs();
210
+ if (nonInMemoryServer) {
211
+ await new Promise<void>((resolve, reject) => {
212
+ nonInMemoryServer.close((err) => {
213
+ if (err) return reject(err);
214
+ resolve();
215
+ });
216
+ });
217
+ }
218
+ if (originalCwd) {
219
+ process.chdir(originalCwd);
220
+ }
221
+ if (nonInMemoryWorkspace) {
222
+ try {
223
+ fs.rmSync(nonInMemoryWorkspace, { recursive: true, force: true });
224
+ } catch (e) {
225
+ debugLogger.warn(
226
+ `Could not remove temp dir '${nonInMemoryWorkspace}':`,
227
+ e,
228
+ );
229
+ }
230
+ }
231
+ });
232
+
233
+ it('should return 501 and not fall through to send a second response', async () => {
234
+ vi.mocked(logger.error).mockClear();
235
+ const response = await request(nonInMemoryApp).get('/tasks/metadata');
236
+ expect(response.status).toBe(501);
237
+ expect(response.body).toEqual({
238
+ error:
239
+ 'Listing all task metadata is only supported when using InMemoryTaskStore.',
240
+ });
241
+ // Without return, the handler falls through and triggers ERR_HTTP_HEADERS_SENT
242
+ expect(logger.error).not.toHaveBeenCalled();
243
+ });
244
+ });
245
+ });
packages/a2a-server/src/http/requestStorage.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type express from 'express';
8
+ import { AsyncLocalStorage } from 'node:async_hooks';
9
+
10
+ export const requestStorage = new AsyncLocalStorage<{ req: express.Request }>();
packages/a2a-server/src/http/server.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @license
5
+ * Copyright 2025 Google LLC
6
+ * SPDX-License-Identifier: Apache-2.0
7
+ */
8
+
9
+ import * as url from 'node:url';
10
+ import * as path from 'node:path';
11
+
12
+ import { logger } from '../utils/logger.js';
13
+ import { main } from './app.js';
14
+
15
+ // Check if the module is the main script being run
16
+ const isMainModule =
17
+ path.basename(process.argv[1]) ===
18
+ path.basename(url.fileURLToPath(import.meta.url));
19
+
20
+ if (
21
+ import.meta.url.startsWith('file:') &&
22
+ isMainModule &&
23
+ process.env['NODE_ENV'] !== 'test'
24
+ ) {
25
+ process.on('uncaughtException', (error) => {
26
+ logger.error('Unhandled exception:', error);
27
+ process.exit(1);
28
+ });
29
+
30
+ main().catch((error) => {
31
+ logger.error('[CoreAgent] Unhandled error in main:', error);
32
+ process.exit(1);
33
+ });
34
+ }
packages/a2a-server/src/index.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ export * from './agent/executor.js';
8
+ export * from './http/app.js';
9
+ export * from './types.js';
packages/a2a-server/src/persistence/gcs.test.ts ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { Storage } from '@google-cloud/storage';
8
+ import * as fse from 'fs-extra';
9
+ import * as tar from 'tar';
10
+ import { gzipSync, gunzipSync } from 'node:zlib';
11
+ import { v4 as uuidv4 } from 'uuid';
12
+ import type { Task as SDKTask } from '@a2a-js/sdk';
13
+ import type { TaskStore } from '@a2a-js/sdk/server';
14
+ import {
15
+ describe,
16
+ it,
17
+ expect,
18
+ beforeEach,
19
+ vi,
20
+ type Mocked,
21
+ type MockedClass,
22
+ type Mock,
23
+ } from 'vitest';
24
+
25
+ import { GCSTaskStore, NoOpTaskStore } from './gcs.js';
26
+ import { logger } from '../utils/logger.js';
27
+ import * as configModule from '../config/config.js';
28
+ import { getPersistedState, METADATA_KEY } from '../types.js';
29
+
30
+ // Mock dependencies
31
+ const fsMocks = vi.hoisted(() => ({
32
+ readdir: vi.fn(),
33
+ createReadStream: vi.fn(),
34
+ }));
35
+
36
+ vi.mock('@google-cloud/storage');
37
+ vi.mock('fs-extra', () => ({
38
+ pathExists: vi.fn(),
39
+ readdir: vi.fn(),
40
+ remove: vi.fn(),
41
+ ensureDir: vi.fn(),
42
+ createReadStream: vi.fn(),
43
+ }));
44
+ vi.mock('node:fs', async () => {
45
+ const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
46
+ return {
47
+ ...actual,
48
+ promises: {
49
+ ...actual.promises,
50
+ readdir: fsMocks.readdir,
51
+ },
52
+ createReadStream: fsMocks.createReadStream,
53
+ };
54
+ });
55
+ vi.mock('fs', async () => {
56
+ const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
57
+ return {
58
+ ...actual,
59
+ promises: {
60
+ ...actual.promises,
61
+ readdir: fsMocks.readdir,
62
+ },
63
+ createReadStream: fsMocks.createReadStream,
64
+ };
65
+ });
66
+ vi.mock('tar', async () => {
67
+ const actualFs = await vi.importActual<typeof import('node:fs')>('node:fs');
68
+ return {
69
+ c: vi.fn(({ file }) => {
70
+ if (file) {
71
+ actualFs.writeFileSync(file, Buffer.from('dummy tar content'));
72
+ }
73
+ return Promise.resolve();
74
+ }),
75
+ x: vi.fn().mockResolvedValue(undefined),
76
+ t: vi.fn().mockResolvedValue(undefined),
77
+ r: vi.fn().mockResolvedValue(undefined),
78
+ u: vi.fn().mockResolvedValue(undefined),
79
+ };
80
+ });
81
+ vi.mock('zlib');
82
+ vi.mock('uuid');
83
+ vi.mock('../utils/logger.js', () => ({
84
+ logger: {
85
+ info: vi.fn(),
86
+ warn: vi.fn(),
87
+ error: vi.fn(),
88
+ debug: vi.fn(),
89
+ },
90
+ }));
91
+ vi.mock('../config/config.js', () => ({
92
+ setTargetDir: vi.fn(),
93
+ }));
94
+ vi.mock('node:stream/promises', () => ({
95
+ pipeline: vi.fn(),
96
+ }));
97
+ vi.mock('../types.js', async (importOriginal) => {
98
+ const actual = await importOriginal<typeof import('../types.js')>();
99
+ return {
100
+ ...actual,
101
+ getPersistedState: vi.fn(),
102
+ };
103
+ });
104
+
105
+ const mockStorage = Storage as MockedClass<typeof Storage>;
106
+ const mockFse = fse as Mocked<typeof fse>;
107
+ const mockCreateReadStream = fsMocks.createReadStream;
108
+ const mockTar = tar as Mocked<typeof tar>;
109
+ const mockGzipSync = gzipSync as Mock;
110
+ const mockGunzipSync = gunzipSync as Mock;
111
+ const mockUuidv4 = uuidv4 as Mock;
112
+ const mockSetTargetDir = configModule.setTargetDir as Mock;
113
+ const mockGetPersistedState = getPersistedState as Mock;
114
+ const TEST_METADATA_KEY = METADATA_KEY || '__persistedState';
115
+
116
+ type MockWriteStream = {
117
+ emit: Mock<(event: string, ...args: unknown[]) => boolean>;
118
+ removeListener: Mock<
119
+ (event: string, cb: (error?: Error | null) => void) => MockWriteStream
120
+ >;
121
+ once: Mock<
122
+ (event: string, cb: (error?: Error | null) => void) => MockWriteStream
123
+ >;
124
+ on: Mock<
125
+ (event: string, cb: (error?: Error | null) => void) => MockWriteStream
126
+ >;
127
+ destroy: Mock<() => void>;
128
+ write: Mock<(chunk: unknown, encoding?: unknown, cb?: unknown) => boolean>;
129
+ end: Mock<(cb?: unknown) => void>;
130
+ destroyed: boolean;
131
+ };
132
+
133
+ type MockFile = {
134
+ save: Mock<(data: Buffer | string) => Promise<void>>;
135
+ download: Mock<() => Promise<[Buffer]>>;
136
+ exists: Mock<() => Promise<[boolean]>>;
137
+ createWriteStream: Mock<() => MockWriteStream>;
138
+ };
139
+
140
+ type MockBucket = {
141
+ exists: Mock<() => Promise<[boolean]>>;
142
+ file: Mock<(path: string) => MockFile>;
143
+ name: string;
144
+ };
145
+
146
+ type MockStorageInstance = {
147
+ bucket: Mock<(name: string) => MockBucket>;
148
+ getBuckets: Mock<() => Promise<[Array<{ name: string }>]>>;
149
+ createBucket: Mock<(name: string) => Promise<[MockBucket]>>;
150
+ };
151
+
152
+ describe('GCSTaskStore', () => {
153
+ let bucketName: string;
154
+ let mockBucket: MockBucket;
155
+ let mockFile: MockFile;
156
+ let mockWriteStream: MockWriteStream;
157
+ let mockStorageInstance: MockStorageInstance;
158
+
159
+ beforeEach(() => {
160
+ vi.clearAllMocks();
161
+ bucketName = 'test-bucket';
162
+
163
+ mockWriteStream = {
164
+ emit: vi.fn().mockReturnValue(true),
165
+ removeListener: vi.fn().mockReturnValue(mockWriteStream),
166
+ on: vi.fn((event, cb) => {
167
+ if (event === 'finish') setTimeout(cb, 0); // Simulate async finish
168
+ return mockWriteStream;
169
+ }),
170
+ once: vi.fn((event, cb) => {
171
+ if (event === 'finish') setTimeout(cb, 0); // Simulate async finish return mockWriteStream;
172
+ }),
173
+ destroy: vi.fn(),
174
+ write: vi.fn().mockReturnValue(true),
175
+ end: vi.fn(),
176
+ destroyed: false,
177
+ };
178
+
179
+ mockFile = {
180
+ save: vi.fn().mockResolvedValue(undefined),
181
+ download: vi.fn().mockResolvedValue([Buffer.from('')]),
182
+ exists: vi.fn().mockResolvedValue([true]),
183
+ createWriteStream: vi.fn().mockReturnValue(mockWriteStream),
184
+ };
185
+
186
+ mockBucket = {
187
+ exists: vi.fn().mockResolvedValue([true]),
188
+ file: vi.fn().mockReturnValue(mockFile),
189
+ name: bucketName,
190
+ };
191
+
192
+ mockStorageInstance = {
193
+ bucket: vi.fn().mockReturnValue(mockBucket),
194
+ getBuckets: vi.fn().mockResolvedValue([[{ name: bucketName }]]),
195
+ createBucket: vi.fn().mockResolvedValue([mockBucket]),
196
+ };
197
+ mockStorage.mockReturnValue(mockStorageInstance as unknown as Storage);
198
+
199
+ mockUuidv4.mockReturnValue('test-uuid');
200
+ mockSetTargetDir.mockReturnValue('/tmp/workdir');
201
+ mockGetPersistedState.mockReturnValue({
202
+ _agentSettings: {},
203
+ _taskState: 'submitted',
204
+ });
205
+ (fse.pathExists as Mock).mockResolvedValue(true);
206
+ fsMocks.readdir.mockResolvedValue(['file1.txt']);
207
+ mockFse.remove.mockResolvedValue(undefined);
208
+ mockFse.ensureDir.mockResolvedValue(undefined);
209
+ mockGzipSync.mockReturnValue(Buffer.from('compressed'));
210
+ mockGunzipSync.mockReturnValue(Buffer.from('{}'));
211
+ mockCreateReadStream.mockReturnValue({ on: vi.fn(), pipe: vi.fn() });
212
+ mockFse.createReadStream.mockReturnValue({
213
+ on: vi.fn(),
214
+ pipe: vi.fn(),
215
+ } as unknown as import('node:fs').ReadStream);
216
+ });
217
+
218
+ describe('Constructor & Initialization', () => {
219
+ it('should initialize and check bucket existence', async () => {
220
+ const store = new GCSTaskStore(bucketName);
221
+ await store['ensureBucketInitialized']();
222
+ expect(mockStorage).toHaveBeenCalledTimes(1);
223
+ expect(mockStorageInstance.getBuckets).toHaveBeenCalled();
224
+ expect(logger.info).toHaveBeenCalledWith(
225
+ expect.stringContaining('Bucket test-bucket exists'),
226
+ );
227
+ });
228
+
229
+ it('should create bucket if it does not exist', async () => {
230
+ mockStorageInstance.getBuckets.mockResolvedValue([[]]);
231
+ const store = new GCSTaskStore(bucketName);
232
+ await store['ensureBucketInitialized']();
233
+ expect(mockStorageInstance.createBucket).toHaveBeenCalledWith(bucketName);
234
+ expect(logger.info).toHaveBeenCalledWith(
235
+ expect.stringContaining('Bucket test-bucket created successfully'),
236
+ );
237
+ });
238
+
239
+ it('should throw if bucket creation fails', async () => {
240
+ mockStorageInstance.getBuckets.mockResolvedValue([[]]);
241
+ mockStorageInstance.createBucket.mockRejectedValue(
242
+ new Error('Create failed'),
243
+ );
244
+ const store = new GCSTaskStore(bucketName);
245
+ await expect(store['ensureBucketInitialized']()).rejects.toThrow(
246
+ 'Failed to create GCS bucket test-bucket: Error: Create failed',
247
+ );
248
+ });
249
+ });
250
+
251
+ describe('save', () => {
252
+ const mockTask: SDKTask = {
253
+ id: 'task1',
254
+ contextId: 'ctx1',
255
+ kind: 'task',
256
+ status: { state: 'working' },
257
+ metadata: {},
258
+ };
259
+
260
+ it('should save metadata and workspace', async () => {
261
+ const store = new GCSTaskStore(bucketName);
262
+ await store.save(mockTask);
263
+
264
+ expect(mockFile.save).toHaveBeenCalledTimes(1);
265
+ expect(mockTar.c).toHaveBeenCalledTimes(1);
266
+ expect(mockFse.remove).toHaveBeenCalledTimes(1);
267
+ expect(logger.info).toHaveBeenCalledWith(
268
+ expect.stringContaining('metadata saved to GCS'),
269
+ );
270
+ expect(logger.info).toHaveBeenCalledWith(
271
+ expect.stringContaining('workspace saved to GCS'),
272
+ );
273
+ });
274
+
275
+ it('should handle tar creation failure', async () => {
276
+ mockFse.pathExists.mockImplementation(
277
+ async (path) =>
278
+ !path.toString().includes('task-task1-workspace-test-uuid.tar.gz'),
279
+ );
280
+ const store = new GCSTaskStore(bucketName);
281
+ await expect(store.save(mockTask)).rejects.toThrow(
282
+ 'tar.c command failed to create',
283
+ );
284
+ });
285
+
286
+ it('should throw an error if taskId contains path traversal sequences', async () => {
287
+ const store = new GCSTaskStore('test-bucket');
288
+ const maliciousTask: SDKTask = {
289
+ id: '../../../malicious-task',
290
+ metadata: {
291
+ _internal: {
292
+ agentSettings: {
293
+ cacheDir: '/tmp/cache',
294
+ dataDir: '/tmp/data',
295
+ logDir: '/tmp/logs',
296
+ tempDir: '/tmp/temp',
297
+ },
298
+ taskState: 'working',
299
+ },
300
+ },
301
+ kind: 'task',
302
+ status: {
303
+ state: 'working',
304
+ timestamp: new Date().toISOString(),
305
+ },
306
+ contextId: 'test-context',
307
+ history: [],
308
+ artifacts: [],
309
+ };
310
+ await expect(store.save(maliciousTask)).rejects.toThrow(
311
+ 'Invalid taskId: ../../../malicious-task',
312
+ );
313
+ });
314
+ });
315
+
316
+ describe('load', () => {
317
+ it('should load task metadata and workspace', async () => {
318
+ mockGunzipSync.mockReturnValue(
319
+ Buffer.from(
320
+ JSON.stringify({
321
+ [TEST_METADATA_KEY]: {
322
+ _agentSettings: {},
323
+ _taskState: 'submitted',
324
+ },
325
+ _contextId: 'ctx1',
326
+ }),
327
+ ),
328
+ );
329
+ mockFile.download.mockResolvedValue([Buffer.from('compressed metadata')]);
330
+ mockFile.download.mockResolvedValueOnce([
331
+ Buffer.from('compressed metadata'),
332
+ ]);
333
+ mockBucket.file = vi.fn((path) => {
334
+ const newMockFile = { ...mockFile };
335
+ if (path.includes('metadata')) {
336
+ newMockFile.download = vi
337
+ .fn()
338
+ .mockResolvedValue([Buffer.from('compressed metadata')]);
339
+ newMockFile.exists = vi.fn().mockResolvedValue([true]);
340
+ } else {
341
+ newMockFile.download = vi
342
+ .fn()
343
+ .mockResolvedValue([Buffer.from('compressed workspace')]);
344
+ newMockFile.exists = vi.fn().mockResolvedValue([true]);
345
+ }
346
+ return newMockFile;
347
+ });
348
+
349
+ const store = new GCSTaskStore(bucketName);
350
+ const task = await store.load('task1');
351
+
352
+ expect(task).toBeDefined();
353
+ expect(task?.id).toBe('task1');
354
+ expect(mockBucket.file).toHaveBeenCalledWith(
355
+ 'tasks/task1/metadata.tar.gz',
356
+ );
357
+ expect(mockBucket.file).toHaveBeenCalledWith(
358
+ 'tasks/task1/workspace.tar.gz',
359
+ );
360
+ expect(mockTar.x).toHaveBeenCalledTimes(1);
361
+ expect(mockFse.remove).toHaveBeenCalledTimes(1);
362
+ });
363
+
364
+ it('should return undefined if metadata not found', async () => {
365
+ mockFile.exists.mockResolvedValue([false]);
366
+ const store = new GCSTaskStore(bucketName);
367
+ const task = await store.load('task1');
368
+ expect(task).toBeUndefined();
369
+ expect(mockBucket.file).toHaveBeenCalledWith(
370
+ 'tasks/task1/metadata.tar.gz',
371
+ );
372
+ });
373
+
374
+ it('should load metadata even if workspace not found', async () => {
375
+ mockGunzipSync.mockReturnValue(
376
+ Buffer.from(
377
+ JSON.stringify({
378
+ [TEST_METADATA_KEY]: {
379
+ _agentSettings: {},
380
+ _taskState: 'submitted',
381
+ },
382
+ _contextId: 'ctx1',
383
+ }),
384
+ ),
385
+ );
386
+
387
+ mockBucket.file = vi.fn((path) => {
388
+ const newMockFile = { ...mockFile };
389
+ if (path.includes('workspace.tar.gz')) {
390
+ newMockFile.exists = vi.fn().mockResolvedValue([false]);
391
+ } else {
392
+ newMockFile.exists = vi.fn().mockResolvedValue([true]);
393
+ newMockFile.download = vi
394
+ .fn()
395
+ .mockResolvedValue([Buffer.from('compressed metadata')]);
396
+ }
397
+ return newMockFile;
398
+ });
399
+
400
+ const store = new GCSTaskStore(bucketName);
401
+ const task = await store.load('task1');
402
+
403
+ expect(task).toBeDefined();
404
+ expect(mockTar.x).not.toHaveBeenCalled();
405
+ expect(logger.info).toHaveBeenCalledWith(
406
+ expect.stringContaining('workspace archive not found'),
407
+ );
408
+ });
409
+ });
410
+
411
+ it('should throw an error if taskId contains path traversal sequences', async () => {
412
+ const store = new GCSTaskStore('test-bucket');
413
+ const maliciousTaskId = '../../../malicious-task';
414
+ await expect(store.load(maliciousTaskId)).rejects.toThrow(
415
+ `Invalid taskId: ${maliciousTaskId}`,
416
+ );
417
+ });
418
+ });
419
+
420
+ describe('NoOpTaskStore', () => {
421
+ let realStore: TaskStore;
422
+ let noOpStore: NoOpTaskStore;
423
+
424
+ beforeEach(() => {
425
+ // Create a mock of the real store to delegate to
426
+ realStore = {
427
+ save: vi.fn(),
428
+ load: vi.fn().mockResolvedValue({ id: 'task-123' } as SDKTask),
429
+ };
430
+ noOpStore = new NoOpTaskStore(realStore);
431
+ });
432
+
433
+ it("should not call the real store's save method", async () => {
434
+ const mockTask: SDKTask = { id: 'test-task' } as SDKTask;
435
+ await noOpStore.save(mockTask);
436
+ expect(realStore.save).not.toHaveBeenCalled();
437
+ });
438
+
439
+ it('should delegate the load method to the real store', async () => {
440
+ const taskId = 'task-123';
441
+ const result = await noOpStore.load(taskId);
442
+ expect(realStore.load).toHaveBeenCalledWith(taskId);
443
+ expect(result).toBeDefined();
444
+ expect(result?.id).toBe(taskId);
445
+ });
446
+ });
packages/a2a-server/src/persistence/gcs.ts ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { Storage } from '@google-cloud/storage';
8
+ import { gzipSync, gunzipSync } from 'node:zlib';
9
+ import * as tar from 'tar';
10
+ import * as fse from 'fs-extra';
11
+ import { promises as fsPromises, createReadStream } from 'node:fs';
12
+ import { tmpdir } from '@google/gemini-cli-core';
13
+ import { join } from 'node:path';
14
+ import type { Task as SDKTask } from '@a2a-js/sdk';
15
+ import type { TaskStore } from '@a2a-js/sdk/server';
16
+ import { logger } from '../utils/logger.js';
17
+ import { setTargetDir } from '../config/config.js';
18
+ import { getPersistedState, type PersistedTaskMetadata } from '../types.js';
19
+ import { v4 as uuidv4 } from 'uuid';
20
+
21
+ type ObjectType = 'metadata' | 'workspace';
22
+
23
+ const getTmpArchiveFilename = (taskId: string): string =>
24
+ `task-${taskId}-workspace-${uuidv4()}.tar.gz`;
25
+
26
+ // Validate the taskId to prevent path traversal attacks by ensuring it only contains safe characters.
27
+ const isTaskIdValid = (taskId: string): boolean => {
28
+ // Allow only alphanumeric characters, dashes, and underscores, and ensure it's not empty.
29
+ const validTaskIdRegex = /^[a-zA-Z0-9_-]+$/;
30
+ return validTaskIdRegex.test(taskId);
31
+ };
32
+
33
+ export class GCSTaskStore implements TaskStore {
34
+ private storage: Storage;
35
+ private bucketName: string;
36
+ private bucketInitialized: Promise<void>;
37
+
38
+ constructor(bucketName: string) {
39
+ if (!bucketName) {
40
+ throw new Error('GCS bucket name is required.');
41
+ }
42
+ this.storage = new Storage();
43
+ this.bucketName = bucketName;
44
+ logger.info(`GCSTaskStore initializing with bucket: ${this.bucketName}`);
45
+ // Prerequisites: user account or service account must have storage admin IAM role
46
+ // and the bucket name must be unique.
47
+ this.bucketInitialized = this.initializeBucket();
48
+ }
49
+
50
+ private async initializeBucket(): Promise<void> {
51
+ try {
52
+ const [buckets] = await this.storage.getBuckets();
53
+ const exists = buckets.some((bucket) => bucket.name === this.bucketName);
54
+
55
+ if (!exists) {
56
+ logger.info(
57
+ `Bucket ${this.bucketName} does not exist in the list. Attempting to create...`,
58
+ );
59
+ try {
60
+ await this.storage.createBucket(this.bucketName);
61
+ logger.info(`Bucket ${this.bucketName} created successfully.`);
62
+ } catch (createError) {
63
+ logger.info(
64
+ `Failed to create bucket ${this.bucketName}: ${createError}`,
65
+ );
66
+ throw new Error(
67
+ `Failed to create GCS bucket ${this.bucketName}: ${createError}`,
68
+ );
69
+ }
70
+ } else {
71
+ logger.info(`Bucket ${this.bucketName} exists.`);
72
+ }
73
+ } catch (error) {
74
+ logger.info(
75
+ `Error during bucket initialization for ${this.bucketName}: ${error}`,
76
+ );
77
+ throw new Error(
78
+ `Failed to initialize GCS bucket ${this.bucketName}: ${error}`,
79
+ );
80
+ }
81
+ }
82
+
83
+ private async ensureBucketInitialized(): Promise<void> {
84
+ await this.bucketInitialized;
85
+ }
86
+
87
+ private getObjectPath(taskId: string, type: ObjectType): string {
88
+ if (!isTaskIdValid(taskId)) {
89
+ throw new Error(`Invalid taskId: ${taskId}`);
90
+ }
91
+ return `tasks/${taskId}/${type}.tar.gz`;
92
+ }
93
+
94
+ async save(task: SDKTask): Promise<void> {
95
+ await this.ensureBucketInitialized();
96
+ const taskId = task.id;
97
+ const persistedState = getPersistedState(
98
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
99
+ task.metadata as PersistedTaskMetadata,
100
+ );
101
+
102
+ if (!persistedState) {
103
+ throw new Error(`Task ${taskId} is missing persisted state in metadata.`);
104
+ }
105
+ const workDir = process.cwd();
106
+
107
+ const metadataObjectPath = this.getObjectPath(taskId, 'metadata');
108
+ const workspaceObjectPath = this.getObjectPath(taskId, 'workspace');
109
+
110
+ const dataToStore = task.metadata;
111
+
112
+ try {
113
+ const jsonString = JSON.stringify(dataToStore);
114
+ const compressedMetadata = gzipSync(Buffer.from(jsonString));
115
+ const metadataFile = this.storage
116
+ .bucket(this.bucketName)
117
+ .file(metadataObjectPath);
118
+ await metadataFile.save(compressedMetadata, {
119
+ contentType: 'application/gzip',
120
+ });
121
+ logger.info(
122
+ `Task ${taskId} metadata saved to GCS: gs://${this.bucketName}/${metadataObjectPath}`,
123
+ );
124
+
125
+ if (await fse.pathExists(workDir)) {
126
+ const entries = await fsPromises.readdir(workDir);
127
+ if (entries.length > 0) {
128
+ const tmpArchiveFile = join(tmpdir(), getTmpArchiveFilename(taskId));
129
+ try {
130
+ await tar.c(
131
+ {
132
+ gzip: true,
133
+ file: tmpArchiveFile,
134
+ cwd: workDir,
135
+ portable: true,
136
+ },
137
+ entries,
138
+ );
139
+
140
+ if (!(await fse.pathExists(tmpArchiveFile))) {
141
+ throw new Error(
142
+ `tar.c command failed to create ${tmpArchiveFile}`,
143
+ );
144
+ }
145
+
146
+ const workspaceFile = this.storage
147
+ .bucket(this.bucketName)
148
+ .file(workspaceObjectPath);
149
+ const sourceStream = createReadStream(tmpArchiveFile);
150
+ const destStream = workspaceFile.createWriteStream({
151
+ contentType: 'application/gzip',
152
+ resumable: true,
153
+ });
154
+
155
+ await new Promise<void>((resolve, reject) => {
156
+ sourceStream.on('error', (err) => {
157
+ logger.error(
158
+ `Error in source stream for ${tmpArchiveFile}:`,
159
+ err,
160
+ );
161
+ // Attempt to close destStream if source fails
162
+ if (!destStream.destroyed) {
163
+ destStream.destroy(err);
164
+ }
165
+ reject(err);
166
+ });
167
+
168
+ destStream.on('error', (err) => {
169
+ logger.error(
170
+ `Error in GCS dest stream for ${workspaceObjectPath}:`,
171
+ err,
172
+ );
173
+ reject(err);
174
+ });
175
+
176
+ destStream.on('finish', () => {
177
+ logger.info(
178
+ `GCS destStream finished for ${workspaceObjectPath}`,
179
+ );
180
+ resolve();
181
+ });
182
+
183
+ logger.info(
184
+ `Piping ${tmpArchiveFile} to GCS object ${workspaceObjectPath}`,
185
+ );
186
+ sourceStream.pipe(destStream);
187
+ });
188
+ logger.info(
189
+ `Task ${taskId} workspace saved to GCS: gs://${this.bucketName}/${workspaceObjectPath}`,
190
+ );
191
+ } catch (error) {
192
+ logger.error(
193
+ `Error during workspace save process for ${taskId}:`,
194
+ error,
195
+ );
196
+ throw error;
197
+ } finally {
198
+ logger.info(`Cleaning up temporary file: ${tmpArchiveFile}`);
199
+ try {
200
+ if (await fse.pathExists(tmpArchiveFile)) {
201
+ await fse.remove(tmpArchiveFile);
202
+ logger.info(
203
+ `Successfully removed temporary file: ${tmpArchiveFile}`,
204
+ );
205
+ } else {
206
+ logger.warn(
207
+ `Temporary file not found for cleanup: ${tmpArchiveFile}`,
208
+ );
209
+ }
210
+ } catch (removeError) {
211
+ logger.error(
212
+ `Error removing temporary file ${tmpArchiveFile}:`,
213
+ removeError,
214
+ );
215
+ }
216
+ }
217
+ } else {
218
+ logger.info(
219
+ `Workspace directory ${workDir} is empty, skipping workspace save for task ${taskId}.`,
220
+ );
221
+ }
222
+ } else {
223
+ logger.info(
224
+ `Workspace directory ${workDir} not found, skipping workspace save for task ${taskId}.`,
225
+ );
226
+ }
227
+ } catch (error) {
228
+ logger.error(`Failed to save task ${taskId} to GCS:`, error);
229
+ throw error;
230
+ }
231
+ }
232
+
233
+ async load(taskId: string): Promise<SDKTask | undefined> {
234
+ await this.ensureBucketInitialized();
235
+ const metadataObjectPath = this.getObjectPath(taskId, 'metadata');
236
+ const workspaceObjectPath = this.getObjectPath(taskId, 'workspace');
237
+
238
+ try {
239
+ const metadataFile = this.storage
240
+ .bucket(this.bucketName)
241
+ .file(metadataObjectPath);
242
+ const [metadataExists] = await metadataFile.exists();
243
+ if (!metadataExists) {
244
+ logger.info(`Task ${taskId} metadata not found in GCS.`);
245
+ return undefined;
246
+ }
247
+ const [compressedMetadata] = await metadataFile.download();
248
+ const jsonData = gunzipSync(compressedMetadata).toString();
249
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
250
+ const loadedMetadata = JSON.parse(jsonData);
251
+ logger.info(`Task ${taskId} metadata loaded from GCS.`);
252
+
253
+ const persistedState = getPersistedState(loadedMetadata);
254
+ if (!persistedState) {
255
+ throw new Error(
256
+ `Loaded metadata for task ${taskId} is missing internal persisted state.`,
257
+ );
258
+ }
259
+ const agentSettings = persistedState._agentSettings;
260
+
261
+ const workDir = await setTargetDir(agentSettings);
262
+ await fse.ensureDir(workDir);
263
+ const workspaceFile = this.storage
264
+ .bucket(this.bucketName)
265
+ .file(workspaceObjectPath);
266
+ const [workspaceExists] = await workspaceFile.exists();
267
+ if (workspaceExists) {
268
+ const tmpArchiveFile = join(tmpdir(), getTmpArchiveFilename(taskId));
269
+ try {
270
+ await workspaceFile.download({ destination: tmpArchiveFile });
271
+ await tar.x({ file: tmpArchiveFile, cwd: workDir });
272
+ logger.info(
273
+ `Task ${taskId} workspace restored from GCS to ${workDir}`,
274
+ );
275
+ } finally {
276
+ if (await fse.pathExists(tmpArchiveFile)) {
277
+ await fse.remove(tmpArchiveFile);
278
+ }
279
+ }
280
+ } else {
281
+ logger.info(`Task ${taskId} workspace archive not found in GCS.`);
282
+ }
283
+
284
+ return {
285
+ id: taskId,
286
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
287
+ contextId: loadedMetadata._contextId || uuidv4(),
288
+ kind: 'task',
289
+ status: {
290
+ state: persistedState._taskState,
291
+ timestamp: new Date().toISOString(),
292
+ },
293
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
294
+ metadata: loadedMetadata,
295
+ history: [],
296
+ artifacts: [],
297
+ };
298
+ } catch (error) {
299
+ logger.error(`Failed to load task ${taskId} from GCS:`, error);
300
+ throw error;
301
+ }
302
+ }
303
+ }
304
+
305
+ export class NoOpTaskStore implements TaskStore {
306
+ constructor(private realStore: TaskStore) {}
307
+
308
+ async save(task: SDKTask): Promise<void> {
309
+ logger.info(`[NoOpTaskStore] save called for task ${task.id} - IGNORED`);
310
+ return Promise.resolve();
311
+ }
312
+
313
+ async load(taskId: string): Promise<SDKTask | undefined> {
314
+ logger.info(
315
+ `[NoOpTaskStore] load called for task ${taskId}, delegating to real store.`,
316
+ );
317
+ return this.realStore.load(taskId);
318
+ }
319
+ }
packages/a2a-server/src/types.ts ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type {
8
+ MCPServerStatus,
9
+ ToolConfirmationOutcome,
10
+ } from '@google/gemini-cli-core';
11
+ import type { TaskState } from '@a2a-js/sdk';
12
+
13
+ // Interfaces and enums for the CoderAgent protocol.
14
+
15
+ export enum CoderAgentEvent {
16
+ /**
17
+ * An event requesting one or more tool call confirmations.
18
+ */
19
+ ToolCallConfirmationEvent = 'tool-call-confirmation',
20
+ /**
21
+ * An event updating on the status of one or more tool calls.
22
+ */
23
+ ToolCallUpdateEvent = 'tool-call-update',
24
+ /**
25
+ * An event providing text updates on the task.
26
+ */
27
+ TextContentEvent = 'text-content',
28
+ /**
29
+ * An event that indicates a change in the task's execution state.
30
+ */
31
+ StateChangeEvent = 'state-change',
32
+ /**
33
+ * An user-sent event to initiate the agent.
34
+ */
35
+ StateAgentSettingsEvent = 'agent-settings',
36
+ /**
37
+ * An event that contains a thought from the agent.
38
+ */
39
+ ThoughtEvent = 'thought',
40
+ /**
41
+ * An event that contains citation from the agent.
42
+ */
43
+ CitationEvent = 'citation',
44
+ }
45
+
46
+ export interface AgentSettings {
47
+ kind: CoderAgentEvent.StateAgentSettingsEvent;
48
+ workspacePath: string;
49
+ autoExecute?: boolean;
50
+ isTrusted?: boolean;
51
+ }
52
+
53
+ export interface ToolCallConfirmation {
54
+ kind: CoderAgentEvent.ToolCallConfirmationEvent;
55
+ }
56
+
57
+ export interface ToolCallUpdate {
58
+ kind: CoderAgentEvent.ToolCallUpdateEvent;
59
+ }
60
+
61
+ export interface TextContent {
62
+ kind: CoderAgentEvent.TextContentEvent;
63
+ }
64
+
65
+ export interface StateChange {
66
+ kind: CoderAgentEvent.StateChangeEvent;
67
+ }
68
+
69
+ export interface Thought {
70
+ kind: CoderAgentEvent.ThoughtEvent;
71
+ }
72
+
73
+ export interface Citation {
74
+ kind: CoderAgentEvent.CitationEvent;
75
+ }
76
+
77
+ export type ThoughtSummary = {
78
+ subject: string;
79
+ description: string;
80
+ };
81
+
82
+ export interface ToolConfirmationResponse {
83
+ outcome: ToolConfirmationOutcome;
84
+ callId: string;
85
+ }
86
+
87
+ export type CoderAgentMessage =
88
+ | AgentSettings
89
+ | ToolCallConfirmation
90
+ | ToolCallUpdate
91
+ | TextContent
92
+ | StateChange
93
+ | Thought
94
+ | Citation;
95
+
96
+ export interface TaskMetadata {
97
+ id: string;
98
+ contextId: string;
99
+ taskState: TaskState;
100
+ model: string;
101
+ mcpServers: Array<{
102
+ name: string;
103
+ status: MCPServerStatus;
104
+ tools: Array<{
105
+ name: string;
106
+ description: string;
107
+ parameterSchema: unknown;
108
+ }>;
109
+ }>;
110
+ availableTools: Array<{
111
+ name: string;
112
+ description: string;
113
+ parameterSchema: unknown;
114
+ }>;
115
+ }
116
+
117
+ export interface PersistedStateMetadata {
118
+ _agentSettings: AgentSettings;
119
+ _taskState: TaskState;
120
+ }
121
+
122
+ export type PersistedTaskMetadata = { [k: string]: unknown };
123
+
124
+ export const METADATA_KEY = '__persistedState';
125
+
126
+ function isAgentSettings(value: unknown): value is AgentSettings {
127
+ return (
128
+ typeof value === 'object' &&
129
+ value !== null &&
130
+ 'kind' in value &&
131
+ value.kind === CoderAgentEvent.StateAgentSettingsEvent &&
132
+ 'workspacePath' in value &&
133
+ typeof value.workspacePath === 'string'
134
+ );
135
+ }
136
+
137
+ function isPersistedStateMetadata(
138
+ value: unknown,
139
+ ): value is PersistedStateMetadata {
140
+ return (
141
+ typeof value === 'object' &&
142
+ value !== null &&
143
+ '_agentSettings' in value &&
144
+ '_taskState' in value &&
145
+ isAgentSettings(value._agentSettings)
146
+ );
147
+ }
148
+
149
+ export function getPersistedState(
150
+ metadata: PersistedTaskMetadata,
151
+ ): PersistedStateMetadata | undefined {
152
+ const state = metadata?.[METADATA_KEY];
153
+ if (isPersistedStateMetadata(state)) {
154
+ return state;
155
+ }
156
+ return undefined;
157
+ }
158
+
159
+ export function getContextIdFromMetadata(
160
+ metadata: PersistedTaskMetadata | undefined,
161
+ ): string | undefined {
162
+ if (!metadata) {
163
+ return undefined;
164
+ }
165
+ const contextId = metadata['_contextId'];
166
+ return typeof contextId === 'string' ? contextId : undefined;
167
+ }
168
+
169
+ export function getAgentSettingsFromMetadata(
170
+ metadata: PersistedTaskMetadata | undefined,
171
+ ): AgentSettings | undefined {
172
+ if (!metadata) {
173
+ return undefined;
174
+ }
175
+ const coderAgent = metadata['coderAgent'];
176
+ if (isAgentSettings(coderAgent)) {
177
+ return coderAgent;
178
+ }
179
+ return undefined;
180
+ }
181
+
182
+ export function setPersistedState(
183
+ metadata: PersistedTaskMetadata,
184
+ state: PersistedStateMetadata,
185
+ ): PersistedTaskMetadata {
186
+ return {
187
+ ...metadata,
188
+ [METADATA_KEY]: state,
189
+ };
190
+ }
packages/a2a-server/src/utils/executor_utils.ts ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { Message } from '@a2a-js/sdk';
8
+ import type { ExecutionEventBus } from '@a2a-js/sdk/server';
9
+ import { v4 as uuidv4 } from 'uuid';
10
+
11
+ import { CoderAgentEvent, type StateChange } from '../types.js';
12
+
13
+ export async function pushTaskStateFailed(
14
+ error: unknown,
15
+ eventBus: ExecutionEventBus,
16
+ taskId: string,
17
+ contextId: string,
18
+ ) {
19
+ const errorMessage =
20
+ error instanceof Error ? error.message : 'Agent execution error';
21
+ const stateChange: StateChange = {
22
+ kind: CoderAgentEvent.StateChangeEvent,
23
+ };
24
+ eventBus.publish({
25
+ kind: 'status-update',
26
+ taskId,
27
+ contextId,
28
+ status: {
29
+ state: 'failed',
30
+ message: {
31
+ kind: 'message',
32
+ role: 'agent',
33
+ parts: [
34
+ {
35
+ kind: 'text',
36
+ text: errorMessage,
37
+ },
38
+ ],
39
+ messageId: uuidv4(),
40
+ taskId,
41
+ contextId,
42
+ } as Message,
43
+ },
44
+ final: true,
45
+ metadata: {
46
+ coderAgent: stateChange,
47
+ model: 'unknown',
48
+ error: errorMessage,
49
+ },
50
+ });
51
+ }
packages/a2a-server/src/utils/logger.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import winston from 'winston';
8
+
9
+ const logger = winston.createLogger({
10
+ level: 'info',
11
+ format: winston.format.combine(
12
+ // First, add a timestamp to the log info object
13
+ winston.format.timestamp({
14
+ format: 'YYYY-MM-DD HH:mm:ss.SSS A', // Custom timestamp format
15
+ }),
16
+ // Here we define the custom output format
17
+ winston.format.printf((info) => {
18
+ const { level, timestamp, message, ...rest } = info;
19
+ return (
20
+ `[${level.toUpperCase()}] ${timestamp} -- ${message}` +
21
+ `${Object.keys(rest).length > 0 ? `\n${JSON.stringify(rest, null, 2)}` : ''}`
22
+ ); // Only print ...rest if present
23
+ }),
24
+ ),
25
+ transports: [new winston.transports.Console()],
26
+ });
27
+
28
+ export { logger };
packages/a2a-server/src/utils/path_utils.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import { resolveToRealPath, isSubpath } from '@google/gemini-cli-core';
10
+
11
+ /**
12
+ * Validates a workspace path to prevent path traversal attacks.
13
+ *
14
+ * @param workspacePath The path to validate.
15
+ * @param allowedRoot The root directory the path must be within. Defaults to CWD.
16
+ * @returns The resolved, safe path.
17
+ * @throws An error if the path is invalid or outside the allowed root.
18
+ */
19
+ export async function validateWorkspacePath(
20
+ workspacePath?: string,
21
+ allowedRoot: string = process.cwd(),
22
+ ): Promise<string> {
23
+ const trimmedPath = workspacePath?.trim();
24
+ if (!trimmedPath) {
25
+ return resolveToRealPath(allowedRoot);
26
+ }
27
+
28
+ if (trimmedPath.includes('\0')) {
29
+ throw new Error('Security violation: Null byte detected in path.');
30
+ }
31
+
32
+ try {
33
+ const canonicalAllowedRoot = resolveToRealPath(allowedRoot);
34
+ const resolvedWorkspacePath = path.resolve(
35
+ canonicalAllowedRoot,
36
+ trimmedPath,
37
+ );
38
+ const canonicalWorkspacePath = resolveToRealPath(resolvedWorkspacePath);
39
+
40
+ // Check if the resolved path is within the allowed root directory
41
+ if (
42
+ canonicalWorkspacePath !== canonicalAllowedRoot &&
43
+ !isSubpath(canonicalAllowedRoot, canonicalWorkspacePath)
44
+ ) {
45
+ throw new Error(
46
+ `Security violation: The path "${trimmedPath}" is outside the allowed root directory.`,
47
+ );
48
+ }
49
+
50
+ const stats = await fs.promises.stat(canonicalWorkspacePath);
51
+ if (!stats.isDirectory()) {
52
+ throw new Error(`The path "${trimmedPath}" is not a directory.`);
53
+ }
54
+
55
+ return canonicalWorkspacePath;
56
+ } catch (e) {
57
+ if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
58
+ throw new Error(`The path "${trimmedPath}" does not exist.`);
59
+ }
60
+ throw e; // Re-throw other errors
61
+ }
62
+ }