Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .github/actions/create-pull-request/action.yml +55 -0
- .github/actions/post-coverage-comment/action.yml +114 -0
- .github/actions/publish-release/action.yml +359 -0
- .github/actions/push-docker/action.yml +75 -0
- .github/actions/push-sandbox/action.yml +114 -0
- .github/actions/run-tests/action.yml +41 -0
- .github/actions/setup-npmrc/action.yml +24 -0
- .github/actions/tag-npm-release/action.yml +139 -0
- .github/actions/verify-release/action.yml +103 -0
- packages/a2a-server/GEMINI.md +22 -0
- packages/a2a-server/README.md +5 -0
- packages/a2a-server/development-extension-rfc.md +509 -0
- packages/a2a-server/index.ts +7 -0
- packages/a2a-server/package.json +52 -0
- packages/a2a-server/src/agent/executor.test.ts +587 -0
- packages/a2a-server/src/agent/executor.ts +939 -0
- packages/a2a-server/src/agent/race-condition.test.ts +173 -0
- packages/a2a-server/src/agent/task-event-driven.test.ts +762 -0
- packages/a2a-server/src/agent/task.test.ts +916 -0
- packages/a2a-server/src/agent/task.ts +1305 -0
- packages/a2a-server/src/commands/command-registry.test.ts +142 -0
- packages/a2a-server/src/commands/command-registry.ts +51 -0
- packages/a2a-server/src/commands/extensions.test.ts +87 -0
- packages/a2a-server/src/commands/extensions.ts +41 -0
- packages/a2a-server/src/commands/init.test.ts +193 -0
- packages/a2a-server/src/commands/init.ts +169 -0
- packages/a2a-server/src/commands/memory.test.ts +114 -0
- packages/a2a-server/src/commands/memory.ts +74 -0
- packages/a2a-server/src/commands/restore.test.ts +137 -0
- packages/a2a-server/src/commands/restore.ts +156 -0
- packages/a2a-server/src/commands/types.ts +41 -0
- packages/a2a-server/src/config/config.test.ts +585 -0
- packages/a2a-server/src/config/config.ts +708 -0
- packages/a2a-server/src/config/extension.ts +157 -0
- packages/a2a-server/src/config/rce_vulnerability.test.ts +115 -0
- packages/a2a-server/src/config/settings.test.ts +235 -0
- packages/a2a-server/src/config/settings.ts +204 -0
- packages/a2a-server/src/config/workspace_trust.test.ts +179 -0
- packages/a2a-server/src/http/app.test.ts +1266 -0
- packages/a2a-server/src/http/app.ts +435 -0
- packages/a2a-server/src/http/endpoints.test.ts +245 -0
- packages/a2a-server/src/http/requestStorage.ts +10 -0
- packages/a2a-server/src/http/server.ts +34 -0
- packages/a2a-server/src/index.ts +9 -0
- packages/a2a-server/src/persistence/gcs.test.ts +446 -0
- packages/a2a-server/src/persistence/gcs.ts +319 -0
- packages/a2a-server/src/types.ts +190 -0
- packages/a2a-server/src/utils/executor_utils.ts +51 -0
- packages/a2a-server/src/utils/logger.ts +28 -0
- 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 |
+
}
|