mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-30 03:30:59 -07:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0b21f5a8d | |||
| 4aec3cdb24 | |||
| b765fb8af4 | |||
| fb0c7d268f | |||
| be12371380 | |||
| a037b961b1 | |||
| 6c739955c0 |
@@ -82,12 +82,13 @@ they appear in the UI.
|
||||
|
||||
### Model
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ------- |
|
||||
| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
|
||||
| Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` |
|
||||
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
|
||||
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- |
|
||||
| Model | `model.name` | The Gemini model to use for conversations. | `undefined` |
|
||||
| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
|
||||
| Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` |
|
||||
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
|
||||
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
|
||||
|
||||
### Context
|
||||
|
||||
|
||||
@@ -19,16 +19,7 @@ can find your npm cache path by running `npm config get cache`.
|
||||
rm -rf "$(npm config get cache)/_npx"
|
||||
```
|
||||
|
||||
**For Windows**
|
||||
|
||||
_Command Prompt_
|
||||
|
||||
```cmd
|
||||
:: The path is typically %LocalAppData%\npm-cache\_npx
|
||||
rmdir /s /q "%LocalAppData%\npm-cache\_npx"
|
||||
```
|
||||
|
||||
_PowerShell_
|
||||
**For Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
# The path is typically $env:LocalAppData\npm-cache\_npx
|
||||
|
||||
Generated
+10
-34
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"version": "0.30.0-main",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"version": "0.30.0-main",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -2292,7 +2292,6 @@
|
||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.2",
|
||||
@@ -2473,7 +2472,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -2523,7 +2521,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
|
||||
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
@@ -2898,7 +2895,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
|
||||
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -2932,7 +2928,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
|
||||
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0"
|
||||
@@ -2987,7 +2982,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
|
||||
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0",
|
||||
@@ -4184,7 +4178,6 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4458,7 +4451,6 @@
|
||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
@@ -5306,7 +5298,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -7860,7 +7851,6 @@
|
||||
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8493,7 +8483,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -9788,7 +9777,6 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz",
|
||||
"integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -10068,7 +10056,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
|
||||
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -13718,7 +13705,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13729,7 +13715,6 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -15689,7 +15674,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -15913,8 +15897,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -15922,7 +15905,6 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -16082,7 +16064,6 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -16291,7 +16272,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -16405,7 +16385,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16418,7 +16397,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -17063,7 +17041,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -17079,7 +17056,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"version": "0.30.0-main",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.8",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -17137,7 +17114,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"version": "0.30.0-main",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
@@ -17220,7 +17197,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"version": "0.30.0-main",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.8",
|
||||
@@ -17463,7 +17440,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17486,7 +17462,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"version": "0.30.0-main",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.16.0"
|
||||
@@ -17501,7 +17477,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"version": "0.30.0-main",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -17518,7 +17494,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"version": "0.30.0-main",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -17535,7 +17511,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"version": "0.30.0-main",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"version": "0.30.0-main",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-main"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"version": "0.30.0-main",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"version": "0.30.0-main",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -26,7 +26,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-main"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
|
||||
@@ -859,6 +859,7 @@ export async function loadCliConfig(
|
||||
fakeResponses: argv.fakeResponses,
|
||||
recordResponses: argv.recordResponses,
|
||||
retryFetchErrors: settings.general?.retryFetchErrors,
|
||||
maxAttempts: settings.general?.maxAttempts,
|
||||
ptyInfo: ptyInfo?.name,
|
||||
disableLLMCorrection: settings.tools?.disableLLMCorrection,
|
||||
rawOutput: argv.rawOutput,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { createTestMergedSettings } from './settings.js';
|
||||
import { createExtension } from '../test-utils/createExtension.js';
|
||||
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
|
||||
|
||||
const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home'));
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const mockedOs = await importOriginal<typeof os>();
|
||||
return {
|
||||
...mockedOs,
|
||||
homedir: mockHomedir,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: mockHomedir,
|
||||
};
|
||||
});
|
||||
|
||||
describe('ExtensionManager', () => {
|
||||
let tempHomeDir: string;
|
||||
let tempWorkspaceDir: string;
|
||||
let userExtensionsDir: string;
|
||||
let extensionManager: ExtensionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tempHomeDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
|
||||
);
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
|
||||
);
|
||||
mockHomedir.mockReturnValue(tempHomeDir);
|
||||
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
settings: createTestMergedSettings(),
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
} catch (_e) {
|
||||
// Ignore
|
||||
}
|
||||
});
|
||||
|
||||
describe('loadExtensions parallel loading', () => {
|
||||
it('should prevent concurrent loading and return the same promise', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'ext1',
|
||||
version: '1.0.0',
|
||||
});
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'ext2',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
// Call loadExtensions twice concurrently
|
||||
const promise1 = extensionManager.loadExtensions();
|
||||
const promise2 = extensionManager.loadExtensions();
|
||||
|
||||
// They should resolve to the exact same array
|
||||
const [extensions1, extensions2] = await Promise.all([
|
||||
promise1,
|
||||
promise2,
|
||||
]);
|
||||
|
||||
expect(extensions1).toBe(extensions2);
|
||||
expect(extensions1).toHaveLength(2);
|
||||
|
||||
const names = extensions1.map((ext) => ext.name).sort();
|
||||
expect(names).toEqual(['ext1', 'ext2']);
|
||||
});
|
||||
|
||||
it('should throw an error if loadExtensions is called after it has already resolved', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'ext1',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
await expect(extensionManager.loadExtensions()).rejects.toThrow(
|
||||
'Extensions already loaded, only load extensions once.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw if extension directory does not exist', async () => {
|
||||
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
expect(extensions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should throw if there are duplicate extension names', async () => {
|
||||
// We manually create two extensions with different dirs but same name in config
|
||||
const ext1Dir = path.join(userExtensionsDir, 'ext1-dir');
|
||||
const ext2Dir = path.join(userExtensionsDir, 'ext2-dir');
|
||||
fs.mkdirSync(ext1Dir, { recursive: true });
|
||||
fs.mkdirSync(ext2Dir, { recursive: true });
|
||||
|
||||
const config = JSON.stringify({
|
||||
name: 'duplicate-ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
fs.writeFileSync(path.join(ext1Dir, 'gemini-extension.json'), config);
|
||||
fs.writeFileSync(
|
||||
path.join(ext1Dir, 'metadata.json'),
|
||||
JSON.stringify({ type: 'local', source: ext1Dir }),
|
||||
);
|
||||
|
||||
fs.writeFileSync(path.join(ext2Dir, 'gemini-extension.json'), config);
|
||||
fs.writeFileSync(
|
||||
path.join(ext2Dir, 'metadata.json'),
|
||||
JSON.stringify({ type: 'local', source: ext2Dir }),
|
||||
);
|
||||
|
||||
await expect(extensionManager.loadExtensions()).rejects.toThrow(
|
||||
'Extension with name duplicate-ext already was loaded.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should wait for loadExtensions to finish when loadExtension is called concurrently', async () => {
|
||||
// Create an initial extension that loadExtensions will find
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'ext1',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
// Start the parallel load (it will read ext1)
|
||||
const loadAllPromise = extensionManager.loadExtensions();
|
||||
|
||||
// Create a second extension dynamically in a DIFFERENT directory
|
||||
// so that loadExtensions (which scans userExtensionsDir) doesn't find it.
|
||||
const externalDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'external-ext-'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(externalDir, 'gemini-extension.json'),
|
||||
JSON.stringify({ name: 'ext2', version: '1.0.0' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(externalDir, 'metadata.json'),
|
||||
JSON.stringify({ type: 'local', source: externalDir }),
|
||||
);
|
||||
|
||||
// Concurrently call loadExtension (simulating an install or update)
|
||||
const loadSinglePromise = extensionManager.loadExtension(externalDir);
|
||||
|
||||
// Wait for both to complete
|
||||
await Promise.all([loadAllPromise, loadSinglePromise]);
|
||||
|
||||
// Both extensions should now be present in the loadedExtensions array
|
||||
const extensions = extensionManager.getExtensions();
|
||||
expect(extensions).toHaveLength(2);
|
||||
const names = extensions.map((ext) => ext.name).sort();
|
||||
expect(names).toEqual(['ext1', 'ext2']);
|
||||
|
||||
fs.rmSync(externalDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
private telemetryConfig: Config;
|
||||
private workspaceDir: string;
|
||||
private loadedExtensions: GeminiCLIExtension[] | undefined;
|
||||
private loadingPromise: Promise<GeminiCLIExtension[]> | null = null;
|
||||
|
||||
constructor(options: ExtensionManagerParams) {
|
||||
super(options.eventEmitter);
|
||||
@@ -519,31 +520,103 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
throw new Error('Extensions already loaded, only load extensions once.');
|
||||
}
|
||||
|
||||
if (this.settings.admin.extensions.enabled === false) {
|
||||
this.loadedExtensions = [];
|
||||
return this.loadedExtensions;
|
||||
if (this.loadingPromise) {
|
||||
return this.loadingPromise;
|
||||
}
|
||||
|
||||
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
|
||||
this.loadedExtensions = [];
|
||||
if (!fs.existsSync(extensionsDir)) {
|
||||
return this.loadedExtensions;
|
||||
}
|
||||
for (const subdir of fs.readdirSync(extensionsDir)) {
|
||||
const extensionDir = path.join(extensionsDir, subdir);
|
||||
await this.loadExtension(extensionDir);
|
||||
}
|
||||
return this.loadedExtensions;
|
||||
this.loadingPromise = (async () => {
|
||||
try {
|
||||
if (this.settings.admin.extensions.enabled === false) {
|
||||
this.loadedExtensions = [];
|
||||
return this.loadedExtensions;
|
||||
}
|
||||
|
||||
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
|
||||
if (!fs.existsSync(extensionsDir)) {
|
||||
this.loadedExtensions = [];
|
||||
return this.loadedExtensions;
|
||||
}
|
||||
|
||||
const subdirs = await fs.promises.readdir(extensionsDir);
|
||||
const extensionPromises = subdirs.map((subdir) => {
|
||||
const extensionDir = path.join(extensionsDir, subdir);
|
||||
return this._buildExtension(extensionDir);
|
||||
});
|
||||
|
||||
const builtExtensionsOrNull = await Promise.all(extensionPromises);
|
||||
const builtExtensions = builtExtensionsOrNull.filter(
|
||||
(ext): ext is GeminiCLIExtension => ext !== null,
|
||||
);
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
for (const ext of builtExtensions) {
|
||||
if (seenNames.has(ext.name)) {
|
||||
throw new Error(
|
||||
`Extension with name ${ext.name} already was loaded.`,
|
||||
);
|
||||
}
|
||||
seenNames.add(ext.name);
|
||||
}
|
||||
|
||||
this.loadedExtensions = builtExtensions;
|
||||
|
||||
await Promise.all(
|
||||
this.loadedExtensions.map((ext) => this.maybeStartExtension(ext)),
|
||||
);
|
||||
|
||||
return this.loadedExtensions;
|
||||
} finally {
|
||||
this.loadingPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return this.loadingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds `extension` to the list of extensions and starts it if appropriate.
|
||||
*
|
||||
* @internal visible for testing only
|
||||
*/
|
||||
private async loadExtension(
|
||||
async loadExtension(
|
||||
extensionDir: string,
|
||||
): Promise<GeminiCLIExtension | null> {
|
||||
if (this.loadingPromise) {
|
||||
await this.loadingPromise;
|
||||
}
|
||||
this.loadedExtensions ??= [];
|
||||
if (!fs.statSync(extensionDir).isDirectory()) {
|
||||
const extension = await this._buildExtension(extensionDir);
|
||||
if (!extension) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
this.getExtensions().find(
|
||||
(installed) => installed.name === extension.name,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Extension with name ${extension.name} already was loaded.`,
|
||||
);
|
||||
}
|
||||
|
||||
this.loadedExtensions = [...this.loadedExtensions, extension];
|
||||
await this.maybeStartExtension(extension);
|
||||
return extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an extension without side effects (does not mutate loadedExtensions or start it).
|
||||
*/
|
||||
private async _buildExtension(
|
||||
extensionDir: string,
|
||||
): Promise<GeminiCLIExtension | null> {
|
||||
try {
|
||||
const stats = await fs.promises.stat(extensionDir);
|
||||
if (!stats.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -592,13 +665,6 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
try {
|
||||
let config = await this.loadExtensionConfig(effectiveExtensionPath);
|
||||
if (
|
||||
this.getExtensions().find((extension) => extension.name === config.name)
|
||||
) {
|
||||
throw new Error(
|
||||
`Extension with name ${config.name} already was loaded.`,
|
||||
);
|
||||
}
|
||||
|
||||
const extensionId = getExtensionId(config, installMetadata);
|
||||
|
||||
@@ -768,7 +834,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
);
|
||||
}
|
||||
|
||||
const extension: GeminiCLIExtension = {
|
||||
return {
|
||||
name: config.name,
|
||||
version: config.version,
|
||||
path: effectiveExtensionPath,
|
||||
@@ -788,10 +854,6 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
agents: agentLoadResult.agents,
|
||||
themes: config.themes,
|
||||
};
|
||||
this.loadedExtensions = [...this.loadedExtensions, extension];
|
||||
|
||||
await this.maybeStartExtension(extension);
|
||||
return extension;
|
||||
} catch (e) {
|
||||
debugLogger.error(
|
||||
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
|
||||
|
||||
@@ -844,7 +844,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description: 'The Gemini model to use for conversations.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
maxSessionTurns: {
|
||||
type: 'number',
|
||||
|
||||
@@ -23,11 +23,11 @@ import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
export const GITHUB_WORKFLOW_PATHS = [
|
||||
'gemini-dispatch/gemini-dispatch.yml',
|
||||
'gemini-assistant/gemini-invoke.yml',
|
||||
'gemini-assistant/gemini-plan-execute.yml',
|
||||
'gemini-dispatch/gemini-dispatch.yml',
|
||||
'issue-triage/gemini-scheduled-triage.yml',
|
||||
'issue-triage/gemini-triage.yml',
|
||||
'issue-triage/gemini-scheduled-triage.yml',
|
||||
'pr-review/gemini-review.yml',
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"version": "0.30.0-main",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -53,14 +53,14 @@ describe('A2AClientManager', () => {
|
||||
let manager: A2AClientManager;
|
||||
|
||||
// Stable mocks initialized once
|
||||
const sendMessageMock = vi.fn();
|
||||
const sendMessageStreamMock = vi.fn();
|
||||
const getTaskMock = vi.fn();
|
||||
const cancelTaskMock = vi.fn();
|
||||
const getAgentCardMock = vi.fn();
|
||||
const authFetchMock = vi.fn();
|
||||
|
||||
const mockClient = {
|
||||
sendMessage: sendMessageMock,
|
||||
sendMessageStream: sendMessageStreamMock,
|
||||
getTask: getTaskMock,
|
||||
cancelTask: cancelTaskMock,
|
||||
getAgentCard: getAgentCardMock,
|
||||
@@ -178,75 +178,91 @@ describe('A2AClientManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMessage', () => {
|
||||
describe('sendMessageStream', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent');
|
||||
});
|
||||
|
||||
it('should send a message to the correct agent', async () => {
|
||||
sendMessageMock.mockResolvedValue({
|
||||
it('should send a message and return a stream', async () => {
|
||||
const mockResult = {
|
||||
kind: 'message',
|
||||
messageId: 'a',
|
||||
parts: [],
|
||||
role: 'agent',
|
||||
} as SendMessageResult);
|
||||
} as SendMessageResult;
|
||||
|
||||
await manager.sendMessage('TestAgent', 'Hello');
|
||||
expect(sendMessageMock).toHaveBeenCalledWith(
|
||||
sendMessageStreamMock.mockReturnValue(
|
||||
(async function* () {
|
||||
yield mockResult;
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = manager.sendMessageStream('TestAgent', 'Hello');
|
||||
const results = [];
|
||||
for await (const res of stream) {
|
||||
results.push(res);
|
||||
}
|
||||
|
||||
expect(results).toEqual([mockResult]);
|
||||
expect(sendMessageStreamMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.anything(),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use contextId and taskId when provided', async () => {
|
||||
sendMessageMock.mockResolvedValue({
|
||||
kind: 'message',
|
||||
messageId: 'a',
|
||||
parts: [],
|
||||
role: 'agent',
|
||||
} as SendMessageResult);
|
||||
sendMessageStreamMock.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'a',
|
||||
parts: [],
|
||||
role: 'agent',
|
||||
} as SendMessageResult;
|
||||
})(),
|
||||
);
|
||||
|
||||
const expectedContextId = 'user-context-id';
|
||||
const expectedTaskId = 'user-task-id';
|
||||
|
||||
await manager.sendMessage('TestAgent', 'Hello', {
|
||||
const stream = manager.sendMessageStream('TestAgent', 'Hello', {
|
||||
contextId: expectedContextId,
|
||||
taskId: expectedTaskId,
|
||||
});
|
||||
|
||||
const call = sendMessageMock.mock.calls[0][0];
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
const call = sendMessageStreamMock.mock.calls[0][0];
|
||||
expect(call.message.contextId).toBe(expectedContextId);
|
||||
expect(call.message.taskId).toBe(expectedTaskId);
|
||||
});
|
||||
|
||||
it('should return result from client', async () => {
|
||||
const mockResult = {
|
||||
contextId: 'server-context-id',
|
||||
id: 'ctx-1',
|
||||
kind: 'task',
|
||||
status: { state: 'working' },
|
||||
};
|
||||
|
||||
sendMessageMock.mockResolvedValueOnce(mockResult as SendMessageResult);
|
||||
|
||||
const response = await manager.sendMessage('TestAgent', 'Hello');
|
||||
|
||||
expect(response).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('should throw prefixed error on failure', async () => {
|
||||
sendMessageMock.mockRejectedValueOnce(new Error('Network error'));
|
||||
sendMessageStreamMock.mockImplementationOnce(() => {
|
||||
throw new Error('Network error');
|
||||
});
|
||||
|
||||
await expect(manager.sendMessage('TestAgent', 'Hello')).rejects.toThrow(
|
||||
'A2AClient SendMessage Error [TestAgent]: Network error',
|
||||
const stream = manager.sendMessageStream('TestAgent', 'Hello');
|
||||
await expect(async () => {
|
||||
for await (const _ of stream) {
|
||||
// consume
|
||||
}
|
||||
}).rejects.toThrow(
|
||||
'[A2AClientManager] sendMessageStream Error [TestAgent]: Network error',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the agent is not found', async () => {
|
||||
await expect(
|
||||
manager.sendMessage('NonExistentAgent', 'Hello'),
|
||||
).rejects.toThrow("Agent 'NonExistentAgent' not found.");
|
||||
const stream = manager.sendMessageStream('NonExistentAgent', 'Hello');
|
||||
await expect(async () => {
|
||||
for await (const _ of stream) {
|
||||
// consume
|
||||
}
|
||||
}).rejects.toThrow("Agent 'NonExistentAgent' not found.");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,14 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { AgentCard, Message, MessageSendParams, Task } from '@a2a-js/sdk';
|
||||
import type {
|
||||
AgentCard,
|
||||
Message,
|
||||
MessageSendParams,
|
||||
Task,
|
||||
TaskStatusUpdateEvent,
|
||||
TaskArtifactUpdateEvent,
|
||||
} from '@a2a-js/sdk';
|
||||
import {
|
||||
type Client,
|
||||
ClientFactory,
|
||||
@@ -18,7 +25,11 @@ import {
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
export type SendMessageResult = Message | Task;
|
||||
export type SendMessageResult =
|
||||
| Message
|
||||
| Task
|
||||
| TaskStatusUpdateEvent
|
||||
| TaskArtifactUpdateEvent;
|
||||
|
||||
/**
|
||||
* Manages A2A clients and caches loaded agent information.
|
||||
@@ -110,18 +121,18 @@ export class A2AClientManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to a loaded agent.
|
||||
* Sends a message to a loaded agent and returns a stream of responses.
|
||||
* @param agentName The name of the agent to send the message to.
|
||||
* @param message The message content.
|
||||
* @param options Optional context and task IDs to maintain conversation state.
|
||||
* @returns The response from the agent (Message or Task).
|
||||
* @returns An async iterable of responses from the agent (Message or Task).
|
||||
* @throws Error if the agent returns an error response.
|
||||
*/
|
||||
async sendMessage(
|
||||
async *sendMessageStream(
|
||||
agentName: string,
|
||||
message: string,
|
||||
options?: { contextId?: string; taskId?: string },
|
||||
): Promise<SendMessageResult> {
|
||||
options?: { contextId?: string; taskId?: string; signal?: AbortSignal },
|
||||
): AsyncIterable<SendMessageResult> {
|
||||
const client = this.clients.get(agentName);
|
||||
if (!client) {
|
||||
throw new Error(`Agent '${agentName}' not found.`);
|
||||
@@ -136,20 +147,19 @@ export class A2AClientManager {
|
||||
contextId: options?.contextId,
|
||||
taskId: options?.taskId,
|
||||
},
|
||||
configuration: {
|
||||
blocking: true,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
return await client.sendMessage(messageParams);
|
||||
yield* client.sendMessageStream(messageParams, {
|
||||
signal: options?.signal,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const prefix = `A2AClient SendMessage Error [${agentName}]`;
|
||||
const prefix = `[A2AClientManager] sendMessageStream Error [${agentName}]`;
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`${prefix}: ${error.message}`, { cause: error });
|
||||
}
|
||||
throw new Error(
|
||||
`${prefix}: Unexpected error during sendMessage: ${String(error)}`,
|
||||
`${prefix}: Unexpected error during sendMessageStream: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,40 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
extractMessageText,
|
||||
extractTaskText,
|
||||
extractIdsFromResponse,
|
||||
isTerminalState,
|
||||
A2AResultReassembler,
|
||||
} from './a2aUtils.js';
|
||||
import type { Message, Task, TextPart, DataPart, FilePart } from '@a2a-js/sdk';
|
||||
import type { SendMessageResult } from './a2a-client-manager.js';
|
||||
import type {
|
||||
Message,
|
||||
Task,
|
||||
TextPart,
|
||||
DataPart,
|
||||
FilePart,
|
||||
TaskStatusUpdateEvent,
|
||||
TaskArtifactUpdateEvent,
|
||||
} from '@a2a-js/sdk';
|
||||
|
||||
describe('a2aUtils', () => {
|
||||
describe('isTerminalState', () => {
|
||||
it('should return true for completed, failed, canceled, and rejected', () => {
|
||||
expect(isTerminalState('completed')).toBe(true);
|
||||
expect(isTerminalState('failed')).toBe(true);
|
||||
expect(isTerminalState('canceled')).toBe(true);
|
||||
expect(isTerminalState('rejected')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for working, submitted, input-required, auth-required, and unknown', () => {
|
||||
expect(isTerminalState('working')).toBe(false);
|
||||
expect(isTerminalState('submitted')).toBe(false);
|
||||
expect(isTerminalState('input-required')).toBe(false);
|
||||
expect(isTerminalState('auth-required')).toBe(false);
|
||||
expect(isTerminalState('unknown')).toBe(false);
|
||||
expect(isTerminalState(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractIdsFromResponse', () => {
|
||||
it('should extract IDs from a message response', () => {
|
||||
const message: Message = {
|
||||
@@ -25,7 +53,11 @@ describe('a2aUtils', () => {
|
||||
};
|
||||
|
||||
const result = extractIdsFromResponse(message);
|
||||
expect(result).toEqual({ contextId: 'ctx-1', taskId: 'task-1' });
|
||||
expect(result).toEqual({
|
||||
contextId: 'ctx-1',
|
||||
taskId: 'task-1',
|
||||
clearTaskId: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract IDs from an in-progress task response', () => {
|
||||
@@ -37,7 +69,76 @@ describe('a2aUtils', () => {
|
||||
};
|
||||
|
||||
const result = extractIdsFromResponse(task);
|
||||
expect(result).toEqual({ contextId: 'ctx-2', taskId: 'task-2' });
|
||||
expect(result).toEqual({
|
||||
contextId: 'ctx-2',
|
||||
taskId: 'task-2',
|
||||
clearTaskId: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set clearTaskId true for terminal task response', () => {
|
||||
const task: Task = {
|
||||
id: 'task-3',
|
||||
contextId: 'ctx-3',
|
||||
kind: 'task',
|
||||
status: { state: 'completed' },
|
||||
};
|
||||
|
||||
const result = extractIdsFromResponse(task);
|
||||
expect(result.clearTaskId).toBe(true);
|
||||
});
|
||||
|
||||
it('should set clearTaskId true for terminal status update', () => {
|
||||
const update = {
|
||||
kind: 'status-update',
|
||||
contextId: 'ctx-4',
|
||||
taskId: 'task-4',
|
||||
final: true,
|
||||
status: { state: 'failed' },
|
||||
};
|
||||
|
||||
const result = extractIdsFromResponse(
|
||||
update as unknown as TaskStatusUpdateEvent,
|
||||
);
|
||||
expect(result.contextId).toBe('ctx-4');
|
||||
expect(result.taskId).toBe('task-4');
|
||||
expect(result.clearTaskId).toBe(true);
|
||||
});
|
||||
|
||||
it('should extract IDs from an artifact-update event', () => {
|
||||
const update = {
|
||||
kind: 'artifact-update',
|
||||
taskId: 'task-5',
|
||||
contextId: 'ctx-5',
|
||||
artifact: {
|
||||
artifactId: 'art-1',
|
||||
parts: [{ kind: 'text', text: 'artifact content' }],
|
||||
},
|
||||
} as unknown as TaskArtifactUpdateEvent;
|
||||
|
||||
const result = extractIdsFromResponse(update);
|
||||
expect(result).toEqual({
|
||||
contextId: 'ctx-5',
|
||||
taskId: 'task-5',
|
||||
clearTaskId: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract taskId from status update event', () => {
|
||||
const update = {
|
||||
kind: 'status-update',
|
||||
taskId: 'task-6',
|
||||
contextId: 'ctx-6',
|
||||
final: false,
|
||||
status: { state: 'working' },
|
||||
};
|
||||
|
||||
const result = extractIdsFromResponse(
|
||||
update as unknown as TaskStatusUpdateEvent,
|
||||
);
|
||||
expect(result.taskId).toBe('task-6');
|
||||
expect(result.contextId).toBe('ctx-6');
|
||||
expect(result.clearTaskId).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,49 +224,65 @@ describe('a2aUtils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTaskText', () => {
|
||||
it('should extract basic task info (clean)', () => {
|
||||
const task: Task = {
|
||||
id: 'task-1',
|
||||
contextId: 'ctx-1',
|
||||
kind: 'task',
|
||||
describe('A2AResultReassembler', () => {
|
||||
it('should reassemble sequential messages and incremental artifacts', () => {
|
||||
const reassembler = new A2AResultReassembler();
|
||||
|
||||
// 1. Initial status
|
||||
reassembler.update({
|
||||
kind: 'status-update',
|
||||
taskId: 't1',
|
||||
status: {
|
||||
state: 'working',
|
||||
message: {
|
||||
kind: 'message',
|
||||
role: 'agent',
|
||||
messageId: 'm1',
|
||||
parts: [{ kind: 'text', text: 'Processing...' } as TextPart],
|
||||
},
|
||||
parts: [{ kind: 'text', text: 'Analyzing...' }],
|
||||
} as Message,
|
||||
},
|
||||
};
|
||||
} as unknown as SendMessageResult);
|
||||
|
||||
const result = extractTaskText(task);
|
||||
expect(result).not.toContain('ID: task-1');
|
||||
expect(result).not.toContain('State: working');
|
||||
expect(result).toBe('Processing...');
|
||||
});
|
||||
// 2. First artifact chunk
|
||||
reassembler.update({
|
||||
kind: 'artifact-update',
|
||||
taskId: 't1',
|
||||
append: false,
|
||||
artifact: {
|
||||
artifactId: 'a1',
|
||||
name: 'Code',
|
||||
parts: [{ kind: 'text', text: 'print(' }],
|
||||
},
|
||||
} as unknown as SendMessageResult);
|
||||
|
||||
it('should extract artifacts with headers', () => {
|
||||
const task: Task = {
|
||||
id: 'task-1',
|
||||
contextId: 'ctx-1',
|
||||
kind: 'task',
|
||||
status: { state: 'completed' },
|
||||
artifacts: [
|
||||
{
|
||||
artifactId: 'art-1',
|
||||
name: 'Report',
|
||||
parts: [{ kind: 'text', text: 'This is the report.' } as TextPart],
|
||||
},
|
||||
],
|
||||
};
|
||||
// 3. Second status
|
||||
reassembler.update({
|
||||
kind: 'status-update',
|
||||
taskId: 't1',
|
||||
status: {
|
||||
state: 'working',
|
||||
message: {
|
||||
kind: 'message',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Processing...' }],
|
||||
} as Message,
|
||||
},
|
||||
} as unknown as SendMessageResult);
|
||||
|
||||
const result = extractTaskText(task);
|
||||
expect(result).toContain('Artifact (Report):');
|
||||
expect(result).toContain('This is the report.');
|
||||
expect(result).not.toContain('Artifacts:');
|
||||
expect(result).not.toContain(' - Name: Report');
|
||||
// 4. Second artifact chunk (append)
|
||||
reassembler.update({
|
||||
kind: 'artifact-update',
|
||||
taskId: 't1',
|
||||
append: true,
|
||||
artifact: {
|
||||
artifactId: 'a1',
|
||||
parts: [{ kind: 'text', text: '"Done")' }],
|
||||
},
|
||||
} as unknown as SendMessageResult);
|
||||
|
||||
const output = reassembler.toString();
|
||||
expect(output).toBe(
|
||||
'Analyzing...\n\nProcessing...\n\nArtifact (Code):\nprint("Done")',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,12 +6,120 @@
|
||||
|
||||
import type {
|
||||
Message,
|
||||
Task,
|
||||
Part,
|
||||
TextPart,
|
||||
DataPart,
|
||||
FilePart,
|
||||
Artifact,
|
||||
TaskState,
|
||||
TaskStatusUpdateEvent,
|
||||
} from '@a2a-js/sdk';
|
||||
import type { SendMessageResult } from './a2a-client-manager.js';
|
||||
|
||||
/**
|
||||
* Reassembles incremental A2A streaming updates into a coherent result.
|
||||
* Shows sequential status/messages followed by all reassembled artifacts.
|
||||
*/
|
||||
export class A2AResultReassembler {
|
||||
private messageLog: string[] = [];
|
||||
private artifacts = new Map<string, Artifact>();
|
||||
private artifactChunks = new Map<string, string[]>();
|
||||
|
||||
/**
|
||||
* Processes a new chunk from the A2A stream.
|
||||
*/
|
||||
update(chunk: SendMessageResult) {
|
||||
if (!('kind' in chunk)) return;
|
||||
|
||||
switch (chunk.kind) {
|
||||
case 'status-update':
|
||||
this.pushMessage(chunk.status?.message);
|
||||
break;
|
||||
|
||||
case 'artifact-update':
|
||||
if (chunk.artifact) {
|
||||
const id = chunk.artifact.artifactId;
|
||||
const existing = this.artifacts.get(id);
|
||||
|
||||
if (chunk.append && existing) {
|
||||
for (const part of chunk.artifact.parts) {
|
||||
existing.parts.push(structuredClone(part));
|
||||
}
|
||||
} else {
|
||||
this.artifacts.set(id, structuredClone(chunk.artifact));
|
||||
}
|
||||
|
||||
const newText = extractPartsText(chunk.artifact.parts, '');
|
||||
let chunks = this.artifactChunks.get(id);
|
||||
if (!chunks) {
|
||||
chunks = [];
|
||||
this.artifactChunks.set(id, chunks);
|
||||
}
|
||||
if (chunk.append) {
|
||||
chunks.push(newText);
|
||||
} else {
|
||||
chunks.length = 0;
|
||||
chunks.push(newText);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'task':
|
||||
this.pushMessage(chunk.status?.message);
|
||||
if (chunk.artifacts) {
|
||||
for (const art of chunk.artifacts) {
|
||||
this.artifacts.set(art.artifactId, structuredClone(art));
|
||||
this.artifactChunks.set(art.artifactId, [
|
||||
extractPartsText(art.parts, ''),
|
||||
]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'message': {
|
||||
this.pushMessage(chunk);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private pushMessage(message: Message | undefined) {
|
||||
if (!message) return;
|
||||
const text = extractPartsText(message.parts, '\n');
|
||||
if (text && this.messageLog[this.messageLog.length - 1] !== text) {
|
||||
this.messageLog.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable string representation of the current reassembled state.
|
||||
*/
|
||||
toString(): string {
|
||||
const joinedMessages = this.messageLog.join('\n\n');
|
||||
|
||||
const artifactsOutput = Array.from(this.artifacts.keys())
|
||||
.map((id) => {
|
||||
const chunks = this.artifactChunks.get(id);
|
||||
const artifact = this.artifacts.get(id);
|
||||
if (!chunks || !artifact) return '';
|
||||
const content = chunks.join('');
|
||||
const header = artifact.name
|
||||
? `Artifact (${artifact.name}):`
|
||||
: 'Artifact:';
|
||||
return `${header}\n${content}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
if (joinedMessages && artifactsOutput) {
|
||||
return `${joinedMessages}\n\n${artifactsOutput}`;
|
||||
}
|
||||
return joinedMessages || artifactsOutput;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a human-readable text representation from a Message object.
|
||||
@@ -22,7 +130,23 @@ export function extractMessageText(message: Message | undefined): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
return extractPartsText(message.parts);
|
||||
return extractPartsText(message.parts, '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text from an array of parts, joining them with the specified separator.
|
||||
*/
|
||||
function extractPartsText(
|
||||
parts: Part[] | undefined,
|
||||
separator: string,
|
||||
): string {
|
||||
if (!parts || parts.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return parts
|
||||
.map((p) => extractPartText(p))
|
||||
.filter(Boolean)
|
||||
.join(separator);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,50 +176,6 @@ function extractPartText(part: Part): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a clean, human-readable text summary from a Task object.
|
||||
* Includes the status message and any artifact content with context headers.
|
||||
* Technical metadata like ID and State are omitted for better clarity and token efficiency.
|
||||
*/
|
||||
export function extractTaskText(task: Task): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Status Message
|
||||
const statusMessageText = extractMessageText(task.status?.message);
|
||||
if (statusMessageText) {
|
||||
parts.push(statusMessageText);
|
||||
}
|
||||
|
||||
// Artifacts
|
||||
if (task.artifacts) {
|
||||
for (const artifact of task.artifacts) {
|
||||
const artifactContent = extractPartsText(artifact.parts);
|
||||
|
||||
if (artifactContent) {
|
||||
const header = artifact.name
|
||||
? `Artifact (${artifact.name}):`
|
||||
: 'Artifact:';
|
||||
parts.push(`${header}\n${artifactContent}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text from an array of parts.
|
||||
*/
|
||||
function extractPartsText(parts: Part[] | undefined): string {
|
||||
if (!parts || parts.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return parts
|
||||
.map((p) => extractPartText(p))
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// Type Guards
|
||||
|
||||
function isTextPart(part: Part): part is TextPart {
|
||||
@@ -110,36 +190,58 @@ function isFilePart(part: Part): part is FilePart {
|
||||
return part.kind === 'file';
|
||||
}
|
||||
|
||||
function isStatusUpdateEvent(
|
||||
result: SendMessageResult,
|
||||
): result is TaskStatusUpdateEvent {
|
||||
return result.kind === 'status-update';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts contextId and taskId from a Message or Task response.
|
||||
* Returns true if the given state is a terminal state for a task.
|
||||
*/
|
||||
export function isTerminalState(state: TaskState | undefined): boolean {
|
||||
return (
|
||||
state === 'completed' ||
|
||||
state === 'failed' ||
|
||||
state === 'canceled' ||
|
||||
state === 'rejected'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts contextId and taskId from a Message, Task, or Update response.
|
||||
* Follows the pattern from the A2A CLI sample to maintain conversational continuity.
|
||||
*/
|
||||
export function extractIdsFromResponse(result: Message | Task): {
|
||||
export function extractIdsFromResponse(result: SendMessageResult): {
|
||||
contextId?: string;
|
||||
taskId?: string;
|
||||
clearTaskId?: boolean;
|
||||
} {
|
||||
let contextId: string | undefined;
|
||||
let taskId: string | undefined;
|
||||
let clearTaskId = false;
|
||||
|
||||
if (result.kind === 'message') {
|
||||
taskId = result.taskId;
|
||||
contextId = result.contextId;
|
||||
} else if (result.kind === 'task') {
|
||||
taskId = result.id;
|
||||
contextId = result.contextId;
|
||||
|
||||
// If the task is in a final state (and not input-required), we clear the taskId
|
||||
// so that the next interaction starts a fresh task (or keeps context without being bound to the old task).
|
||||
if (
|
||||
result.status &&
|
||||
result.status.state !== 'input-required' &&
|
||||
(result.status.state === 'completed' ||
|
||||
result.status.state === 'failed' ||
|
||||
result.status.state === 'canceled')
|
||||
) {
|
||||
taskId = undefined;
|
||||
if ('kind' in result) {
|
||||
const kind = result.kind;
|
||||
if (kind === 'message' || kind === 'artifact-update') {
|
||||
taskId = result.taskId;
|
||||
contextId = result.contextId;
|
||||
} else if (kind === 'task') {
|
||||
taskId = result.id;
|
||||
contextId = result.contextId;
|
||||
if (isTerminalState(result.status?.state)) {
|
||||
clearTaskId = true;
|
||||
}
|
||||
} else if (isStatusUpdateEvent(result)) {
|
||||
taskId = result.taskId;
|
||||
contextId = result.contextId;
|
||||
// Note: We ignore the 'final' flag here per A2A protocol best practices,
|
||||
// as a stream can close while a task is still in a 'working' state.
|
||||
if (isTerminalState(result.status?.state)) {
|
||||
clearTaskId = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { contextId, taskId };
|
||||
return { contextId, taskId, clearTaskId };
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { RemoteAgentInvocation } from './remote-invocation.js';
|
||||
import { A2AClientManager } from './a2a-client-manager.js';
|
||||
import {
|
||||
A2AClientManager,
|
||||
type SendMessageResult,
|
||||
} from './a2a-client-manager.js';
|
||||
import type { RemoteAgentDefinition } from './types.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
|
||||
@@ -41,7 +44,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
const mockClientManager = {
|
||||
getClient: vi.fn(),
|
||||
loadAgent: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
sendMessageStream: vi.fn(),
|
||||
};
|
||||
const mockMessageBus = createMockMessageBus();
|
||||
|
||||
@@ -78,12 +81,16 @@ describe('RemoteAgentInvocation', () => {
|
||||
|
||||
it('uses "Get Started!" default when query is missing during execution', async () => {
|
||||
mockClientManager.getClient.mockReturnValue({});
|
||||
mockClientManager.sendMessage.mockResolvedValue({
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Hello' }],
|
||||
});
|
||||
mockClientManager.sendMessageStream.mockImplementation(
|
||||
async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Hello' }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
@@ -92,10 +99,10 @@ describe('RemoteAgentInvocation', () => {
|
||||
);
|
||||
await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(mockClientManager.sendMessage).toHaveBeenCalledWith(
|
||||
expect(mockClientManager.sendMessageStream).toHaveBeenCalledWith(
|
||||
'test-agent',
|
||||
'Get Started!',
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ signal: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -113,12 +120,16 @@ describe('RemoteAgentInvocation', () => {
|
||||
describe('Execution Logic', () => {
|
||||
it('should lazy load the agent with ADCHandler if not present', async () => {
|
||||
mockClientManager.getClient.mockReturnValue(undefined);
|
||||
mockClientManager.sendMessage.mockResolvedValue({
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Hello' }],
|
||||
});
|
||||
mockClientManager.sendMessageStream.mockImplementation(
|
||||
async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Hello' }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
@@ -141,12 +152,16 @@ describe('RemoteAgentInvocation', () => {
|
||||
|
||||
it('should not load the agent if already present', async () => {
|
||||
mockClientManager.getClient.mockReturnValue({});
|
||||
mockClientManager.sendMessage.mockResolvedValue({
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Hello' }],
|
||||
});
|
||||
mockClientManager.sendMessageStream.mockImplementation(
|
||||
async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Hello' }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
@@ -164,14 +179,18 @@ describe('RemoteAgentInvocation', () => {
|
||||
mockClientManager.getClient.mockReturnValue({});
|
||||
|
||||
// First call return values
|
||||
mockClientManager.sendMessage.mockResolvedValueOnce({
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Response 1' }],
|
||||
contextId: 'ctx-1',
|
||||
taskId: 'task-1',
|
||||
});
|
||||
mockClientManager.sendMessageStream.mockImplementationOnce(
|
||||
async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Response 1' }],
|
||||
contextId: 'ctx-1',
|
||||
taskId: 'task-1',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const invocation1 = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
@@ -184,21 +203,25 @@ describe('RemoteAgentInvocation', () => {
|
||||
// Execute first time
|
||||
const result1 = await invocation1.execute(new AbortController().signal);
|
||||
expect(result1.returnDisplay).toBe('Response 1');
|
||||
expect(mockClientManager.sendMessage).toHaveBeenLastCalledWith(
|
||||
expect(mockClientManager.sendMessageStream).toHaveBeenLastCalledWith(
|
||||
'test-agent',
|
||||
'first',
|
||||
{ contextId: undefined, taskId: undefined },
|
||||
{ contextId: undefined, taskId: undefined, signal: expect.any(Object) },
|
||||
);
|
||||
|
||||
// Prepare for second call with simulated state persistence
|
||||
mockClientManager.sendMessage.mockResolvedValueOnce({
|
||||
kind: 'message',
|
||||
messageId: 'msg-2',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Response 2' }],
|
||||
contextId: 'ctx-1',
|
||||
taskId: 'task-2',
|
||||
});
|
||||
mockClientManager.sendMessageStream.mockImplementationOnce(
|
||||
async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-2',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Response 2' }],
|
||||
contextId: 'ctx-1',
|
||||
taskId: 'task-2',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const invocation2 = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
@@ -210,21 +233,25 @@ describe('RemoteAgentInvocation', () => {
|
||||
const result2 = await invocation2.execute(new AbortController().signal);
|
||||
expect(result2.returnDisplay).toBe('Response 2');
|
||||
|
||||
expect(mockClientManager.sendMessage).toHaveBeenLastCalledWith(
|
||||
expect(mockClientManager.sendMessageStream).toHaveBeenLastCalledWith(
|
||||
'test-agent',
|
||||
'second',
|
||||
{ contextId: 'ctx-1', taskId: 'task-1' }, // Used state from first call
|
||||
{ contextId: 'ctx-1', taskId: 'task-1', signal: expect.any(Object) }, // Used state from first call
|
||||
);
|
||||
|
||||
// Third call: Task completes
|
||||
mockClientManager.sendMessage.mockResolvedValueOnce({
|
||||
kind: 'task',
|
||||
id: 'task-2',
|
||||
contextId: 'ctx-1',
|
||||
status: { state: 'completed', message: undefined },
|
||||
artifacts: [],
|
||||
history: [],
|
||||
});
|
||||
mockClientManager.sendMessageStream.mockImplementationOnce(
|
||||
async function* () {
|
||||
yield {
|
||||
kind: 'task',
|
||||
id: 'task-2',
|
||||
contextId: 'ctx-1',
|
||||
status: { state: 'completed', message: undefined },
|
||||
artifacts: [],
|
||||
history: [],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const invocation3 = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
@@ -236,12 +263,16 @@ describe('RemoteAgentInvocation', () => {
|
||||
await invocation3.execute(new AbortController().signal);
|
||||
|
||||
// Fourth call: Should start new task (taskId undefined)
|
||||
mockClientManager.sendMessage.mockResolvedValueOnce({
|
||||
kind: 'message',
|
||||
messageId: 'msg-3',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'New Task' }],
|
||||
});
|
||||
mockClientManager.sendMessageStream.mockImplementationOnce(
|
||||
async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-3',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'New Task' }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const invocation4 = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
@@ -252,17 +283,84 @@ describe('RemoteAgentInvocation', () => {
|
||||
);
|
||||
await invocation4.execute(new AbortController().signal);
|
||||
|
||||
expect(mockClientManager.sendMessage).toHaveBeenLastCalledWith(
|
||||
expect(mockClientManager.sendMessageStream).toHaveBeenLastCalledWith(
|
||||
'test-agent',
|
||||
'fourth',
|
||||
{ contextId: 'ctx-1', taskId: undefined }, // taskId cleared!
|
||||
{ contextId: 'ctx-1', taskId: undefined, signal: expect.any(Object) }, // taskId cleared!
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle streaming updates and reassemble output', async () => {
|
||||
mockClientManager.getClient.mockReturnValue({});
|
||||
mockClientManager.sendMessageStream.mockImplementation(
|
||||
async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Hello' }],
|
||||
};
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Hello World' }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
await invocation.execute(new AbortController().signal, updateOutput);
|
||||
|
||||
expect(updateOutput).toHaveBeenCalledWith('Hello');
|
||||
expect(updateOutput).toHaveBeenCalledWith('Hello\n\nHello World');
|
||||
});
|
||||
|
||||
it('should abort when signal is aborted during streaming', async () => {
|
||||
mockClientManager.getClient.mockReturnValue({});
|
||||
const controller = new AbortController();
|
||||
mockClientManager.sendMessageStream.mockImplementation(
|
||||
async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Partial' }],
|
||||
};
|
||||
// Simulate abort between chunks
|
||||
controller.abort();
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-2',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Partial response continued' }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
const result = await invocation.execute(controller.signal);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error?.message).toContain('Operation aborted');
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockClientManager.getClient.mockReturnValue({});
|
||||
mockClientManager.sendMessage.mockRejectedValue(
|
||||
new Error('Network error'),
|
||||
mockClientManager.sendMessageStream.mockImplementation(
|
||||
async function* () {
|
||||
if (Math.random() < 0) yield {} as unknown as SendMessageResult;
|
||||
throw new Error('Network error');
|
||||
},
|
||||
);
|
||||
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
@@ -282,15 +380,19 @@ describe('RemoteAgentInvocation', () => {
|
||||
it('should use a2a helpers for extracting text', async () => {
|
||||
mockClientManager.getClient.mockReturnValue({});
|
||||
// Mock a complex message part that needs extraction
|
||||
mockClientManager.sendMessage.mockResolvedValue({
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [
|
||||
{ kind: 'text', text: 'Extracted text' },
|
||||
{ kind: 'data', data: { foo: 'bar' } },
|
||||
],
|
||||
});
|
||||
mockClientManager.sendMessageStream.mockImplementation(
|
||||
async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [
|
||||
{ kind: 'text', text: 'Extracted text' },
|
||||
{ kind: 'data', data: { foo: 'bar' } },
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
@@ -304,6 +406,105 @@ describe('RemoteAgentInvocation', () => {
|
||||
// Just check that text is present, exact formatting depends on helper
|
||||
expect(result.returnDisplay).toContain('Extracted text');
|
||||
});
|
||||
|
||||
it('should handle mixed response types during streaming (TaskStatusUpdateEvent + Message)', async () => {
|
||||
mockClientManager.getClient.mockReturnValue({});
|
||||
mockClientManager.sendMessageStream.mockImplementation(
|
||||
async function* () {
|
||||
yield {
|
||||
kind: 'status-update',
|
||||
taskId: 'task-1',
|
||||
contextId: 'ctx-1',
|
||||
final: false,
|
||||
status: {
|
||||
state: 'working',
|
||||
message: {
|
||||
kind: 'message',
|
||||
role: 'agent',
|
||||
messageId: 'm1',
|
||||
parts: [{ kind: 'text', text: 'Thinking...' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-final',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Final Answer' }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
const result = await invocation.execute(
|
||||
new AbortController().signal,
|
||||
updateOutput,
|
||||
);
|
||||
|
||||
expect(updateOutput).toHaveBeenCalledWith('Thinking...');
|
||||
expect(updateOutput).toHaveBeenCalledWith('Thinking...\n\nFinal Answer');
|
||||
expect(result.returnDisplay).toBe('Thinking...\n\nFinal Answer');
|
||||
});
|
||||
|
||||
it('should handle artifact reassembly with append: true', async () => {
|
||||
mockClientManager.getClient.mockReturnValue({});
|
||||
mockClientManager.sendMessageStream.mockImplementation(
|
||||
async function* () {
|
||||
yield {
|
||||
kind: 'status-update',
|
||||
taskId: 'task-1',
|
||||
status: {
|
||||
state: 'working',
|
||||
message: {
|
||||
kind: 'message',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Generating...' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
yield {
|
||||
kind: 'artifact-update',
|
||||
taskId: 'task-1',
|
||||
append: false,
|
||||
artifact: {
|
||||
artifactId: 'art-1',
|
||||
name: 'Result',
|
||||
parts: [{ kind: 'text', text: 'Part 1' }],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
kind: 'artifact-update',
|
||||
taskId: 'task-1',
|
||||
append: true,
|
||||
artifact: {
|
||||
artifactId: 'art-1',
|
||||
parts: [{ kind: 'text', text: ' Part 2' }],
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
await invocation.execute(new AbortController().signal, updateOutput);
|
||||
|
||||
expect(updateOutput).toHaveBeenCalledWith('Generating...');
|
||||
expect(updateOutput).toHaveBeenCalledWith(
|
||||
'Generating...\n\nArtifact (Result):\nPart 1',
|
||||
);
|
||||
expect(updateOutput).toHaveBeenCalledWith(
|
||||
'Generating...\n\nArtifact (Result):\nPart 1 Part 2',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Confirmations', () => {
|
||||
|
||||
@@ -18,14 +18,12 @@ import type {
|
||||
} from './types.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { A2AClientManager } from './a2a-client-manager.js';
|
||||
import {
|
||||
extractMessageText,
|
||||
extractTaskText,
|
||||
extractIdsFromResponse,
|
||||
} from './a2aUtils.js';
|
||||
import { extractIdsFromResponse, A2AResultReassembler } from './a2aUtils.js';
|
||||
import { GoogleAuth } from 'google-auth-library';
|
||||
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||
import type { SendMessageResult } from './a2a-client-manager.js';
|
||||
|
||||
/**
|
||||
* Authentication handler implementation using Google Application Default Credentials (ADC).
|
||||
@@ -123,10 +121,14 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
};
|
||||
}
|
||||
|
||||
async execute(_signal: AbortSignal): Promise<ToolResult> {
|
||||
async execute(
|
||||
_signal: AbortSignal,
|
||||
updateOutput?: (output: string | AnsiOutput) => void,
|
||||
): Promise<ToolResult> {
|
||||
// 1. Ensure the agent is loaded (cached by manager)
|
||||
// We assume the user has provided an access token via some mechanism (TODO),
|
||||
// or we rely on ADC.
|
||||
const reassembler = new A2AResultReassembler();
|
||||
try {
|
||||
const priorState = RemoteAgentInvocation.sessionState.get(
|
||||
this.definition.name,
|
||||
@@ -146,49 +148,73 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
|
||||
const message = this.params.query;
|
||||
|
||||
const response = await this.clientManager.sendMessage(
|
||||
const stream = this.clientManager.sendMessageStream(
|
||||
this.definition.name,
|
||||
message,
|
||||
{
|
||||
contextId: this.contextId,
|
||||
taskId: this.taskId,
|
||||
signal: _signal,
|
||||
},
|
||||
);
|
||||
|
||||
// Extracts IDs, taskID will be undefined if the task is completed/failed/canceled.
|
||||
const { contextId, taskId } = extractIdsFromResponse(response);
|
||||
let finalResponse: SendMessageResult | undefined;
|
||||
|
||||
this.contextId = contextId ?? this.contextId;
|
||||
this.taskId = taskId;
|
||||
for await (const chunk of stream) {
|
||||
if (_signal.aborted) {
|
||||
throw new Error('Operation aborted');
|
||||
}
|
||||
finalResponse = chunk;
|
||||
reassembler.update(chunk);
|
||||
|
||||
if (updateOutput) {
|
||||
updateOutput(reassembler.toString());
|
||||
}
|
||||
|
||||
const {
|
||||
contextId: newContextId,
|
||||
taskId: newTaskId,
|
||||
clearTaskId,
|
||||
} = extractIdsFromResponse(chunk);
|
||||
|
||||
if (newContextId) {
|
||||
this.contextId = newContextId;
|
||||
}
|
||||
|
||||
this.taskId = clearTaskId ? undefined : (newTaskId ?? this.taskId);
|
||||
}
|
||||
|
||||
if (!finalResponse) {
|
||||
throw new Error('No response from remote agent.');
|
||||
}
|
||||
|
||||
const finalOutput = reassembler.toString();
|
||||
|
||||
debugLogger.debug(
|
||||
`[RemoteAgent] Final response from ${this.definition.name}:\n${JSON.stringify(finalResponse, null, 2)}`,
|
||||
);
|
||||
|
||||
return {
|
||||
llmContent: [{ text: finalOutput }],
|
||||
returnDisplay: finalOutput,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const partialOutput = reassembler.toString();
|
||||
const errorMessage = `Error calling remote agent: ${error instanceof Error ? error.message : String(error)}`;
|
||||
const fullDisplay = partialOutput
|
||||
? `${partialOutput}\n\n${errorMessage}`
|
||||
: errorMessage;
|
||||
return {
|
||||
llmContent: [{ text: fullDisplay }],
|
||||
returnDisplay: fullDisplay,
|
||||
error: { message: errorMessage },
|
||||
};
|
||||
} finally {
|
||||
// Persist state even on partial failures or aborts to maintain conversational continuity.
|
||||
RemoteAgentInvocation.sessionState.set(this.definition.name, {
|
||||
contextId: this.contextId,
|
||||
taskId: this.taskId,
|
||||
});
|
||||
|
||||
// Extract the output text
|
||||
const outputText =
|
||||
response.kind === 'task'
|
||||
? extractTaskText(response)
|
||||
: response.kind === 'message'
|
||||
? extractMessageText(response)
|
||||
: JSON.stringify(response);
|
||||
|
||||
debugLogger.debug(
|
||||
`[RemoteAgent] Response from ${this.definition.name}:\n${JSON.stringify(response, null, 2)}`,
|
||||
);
|
||||
|
||||
return {
|
||||
llmContent: [{ text: outputText }],
|
||||
returnDisplay: outputText,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = `Error calling remote agent: ${error instanceof Error ? error.message : String(error)}`;
|
||||
return {
|
||||
llmContent: [{ text: errorMessage }],
|
||||
returnDisplay: errorMessage,
|
||||
error: { message: errorMessage },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"version": "0.30.0-main",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"version": "0.30.0-main",
|
||||
"description": "Gemini CLI SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"version": "0.30.0-main",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"displayName": "Gemini CLI Companion",
|
||||
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"version": "0.30.0-main",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
Reference in New Issue
Block a user