mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 13:41:05 -07:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03ef280120 | |||
| 60b01761b8 | |||
| df81bfe1f2 | |||
| 1c926921ed | |||
| d1ca874dd1 | |||
| 470228e7a0 | |||
| 9d12980baa | |||
| b85a3bafe5 | |||
| 72ea38b306 | |||
| 57f3c9ca1a | |||
| 5ea957c84b |
@@ -0,0 +1,15 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
.gcp
|
||||||
|
bundle
|
||||||
|
evals
|
||||||
|
integration-tests
|
||||||
|
docs
|
||||||
|
packages/cli
|
||||||
|
packages/vscode-ide-companion
|
||||||
|
packages/test-utils
|
||||||
|
**/*.test.ts
|
||||||
|
**/*.test.js
|
||||||
|
**/src/**/*.ts
|
||||||
|
!packages/a2a-server/dist/**
|
||||||
|
!packages/core/dist/**
|
||||||
Generated
+25
-1
@@ -2255,6 +2255,7 @@
|
|||||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-token": "^6.0.0",
|
"@octokit/auth-token": "^6.0.0",
|
||||||
"@octokit/graphql": "^9.0.2",
|
"@octokit/graphql": "^9.0.2",
|
||||||
@@ -2435,6 +2436,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
@@ -2468,6 +2470,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
|
||||||
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
|
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||||
},
|
},
|
||||||
@@ -2836,6 +2839,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
|
||||||
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
|
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/core": "2.0.1",
|
"@opentelemetry/core": "2.0.1",
|
||||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||||
@@ -2869,6 +2873,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
|
||||||
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
|
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/core": "2.0.1",
|
"@opentelemetry/core": "2.0.1",
|
||||||
"@opentelemetry/resources": "2.0.1"
|
"@opentelemetry/resources": "2.0.1"
|
||||||
@@ -2921,6 +2926,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
|
||||||
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
|
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/core": "2.0.1",
|
"@opentelemetry/core": "2.0.1",
|
||||||
"@opentelemetry/resources": "2.0.1",
|
"@opentelemetry/resources": "2.0.1",
|
||||||
@@ -4136,6 +4142,7 @@
|
|||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@@ -4430,6 +4437,7 @@
|
|||||||
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.35.0",
|
"@typescript-eslint/scope-manager": "8.35.0",
|
||||||
"@typescript-eslint/types": "8.35.0",
|
"@typescript-eslint/types": "8.35.0",
|
||||||
@@ -5422,6 +5430,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -8431,6 +8440,7 @@
|
|||||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -8971,6 +8981,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -10584,6 +10595,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
|
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
|
||||||
"integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
|
"integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||||
"ansi-escapes": "^7.0.0",
|
"ansi-escapes": "^7.0.0",
|
||||||
@@ -14368,6 +14380,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -14378,6 +14391,7 @@
|
|||||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"shell-quote": "^1.6.1",
|
"shell-quote": "^1.6.1",
|
||||||
"ws": "^7"
|
"ws": "^7"
|
||||||
@@ -16614,6 +16628,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -16837,7 +16852,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "0BSD"
|
"license": "0BSD",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.20.3",
|
"version": "4.20.3",
|
||||||
@@ -16845,6 +16861,7 @@
|
|||||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.25.0",
|
"esbuild": "~0.25.0",
|
||||||
"get-tsconfig": "^4.7.5"
|
"get-tsconfig": "^4.7.5"
|
||||||
@@ -17017,6 +17034,7 @@
|
|||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -17224,6 +17242,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -17337,6 +17356,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -17349,6 +17369,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/expect": "3.2.4",
|
"@vitest/expect": "3.2.4",
|
||||||
@@ -18053,6 +18074,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
@@ -18075,6 +18097,7 @@
|
|||||||
"@google/gemini-cli-core": "file:../core",
|
"@google/gemini-cli-core": "file:../core",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"fs-extra": "^11.3.0",
|
"fs-extra": "^11.3.0",
|
||||||
|
"google-auth-library": "^9.11.0",
|
||||||
"tar": "^7.5.2",
|
"tar": "^7.5.2",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"winston": "^3.17.0"
|
"winston": "^3.17.0"
|
||||||
@@ -18351,6 +18374,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Pre-built production image for a2a-server
|
||||||
|
# Used with Cloud Build: npm install + build runs in step 1, then Docker copies artifacts
|
||||||
|
FROM docker.io/library/node:20-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 curl git jq ripgrep ca-certificates \
|
||||||
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy everything including pre-installed node_modules and pre-built dist
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY node_modules/ node_modules/
|
||||||
|
COPY packages/core/ packages/core/
|
||||||
|
COPY packages/a2a-server/ packages/a2a-server/
|
||||||
|
|
||||||
|
# Create workspace directory for agent operations
|
||||||
|
RUN mkdir -p /workspace && chown -R node:node /workspace
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
ENV CODER_AGENT_WORKSPACE_PATH=/workspace
|
||||||
|
ENV CODER_AGENT_PORT=8080
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
# Prevent git from prompting for credentials interactively — fails fast instead of hanging
|
||||||
|
ENV GIT_TERMINAL_PROMPT=0
|
||||||
|
ENV CODER_AGENT_HOST=0.0.0.0
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["node", "packages/a2a-server/dist/src/http/server.js"]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
steps:
|
||||||
|
# Step 1: Install all dependencies and build
|
||||||
|
- name: 'node:20-slim'
|
||||||
|
entrypoint: 'bash'
|
||||||
|
args:
|
||||||
|
- '-c'
|
||||||
|
- |
|
||||||
|
apt-get update && apt-get install -y python3 make g++ git
|
||||||
|
npm pkg delete scripts.prepare
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
env:
|
||||||
|
- 'HUSKY=0'
|
||||||
|
|
||||||
|
# Step 2: Build Docker image (using pre-built dist/ from step 1)
|
||||||
|
- name: 'gcr.io/cloud-builders/docker'
|
||||||
|
args:
|
||||||
|
- 'build'
|
||||||
|
- '-t'
|
||||||
|
- 'us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/a2a-server:latest'
|
||||||
|
- '-f'
|
||||||
|
- 'packages/a2a-server/Dockerfile'
|
||||||
|
- '.'
|
||||||
|
|
||||||
|
# Step 3: Push to Artifact Registry
|
||||||
|
- name: 'gcr.io/cloud-builders/docker'
|
||||||
|
args:
|
||||||
|
- 'push'
|
||||||
|
- 'us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/a2a-server:latest'
|
||||||
|
|
||||||
|
images:
|
||||||
|
- 'us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/a2a-server:latest'
|
||||||
|
timeout: '1800s'
|
||||||
|
options:
|
||||||
|
machineType: 'E2_HIGHCPU_8'
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: gemini-a2a-server
|
||||||
|
labels:
|
||||||
|
app: gemini-a2a-server
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: gemini-a2a-server
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: gemini-a2a-server
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: a2a-server
|
||||||
|
image: us-central1-docker.pkg.dev/adamfweidman-test/gemini-a2a/a2a-server:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
- name: CODER_AGENT_PORT
|
||||||
|
value: "8080"
|
||||||
|
- name: CODER_AGENT_HOST
|
||||||
|
value: "0.0.0.0"
|
||||||
|
- name: CODER_AGENT_WORKSPACE_PATH
|
||||||
|
value: "/workspace"
|
||||||
|
- name: GEMINI_API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: gemini-secrets
|
||||||
|
key: api-key
|
||||||
|
- name: GEMINI_YOLO_MODE
|
||||||
|
value: "true"
|
||||||
|
- name: CHAT_BRIDGE_A2A_URL
|
||||||
|
value: "http://localhost:8080"
|
||||||
|
- name: NODE_ENV
|
||||||
|
value: "production"
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: "500m"
|
||||||
|
memory: "512Mi"
|
||||||
|
limits:
|
||||||
|
cpu: "2000m"
|
||||||
|
memory: "2Gi"
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /.well-known/agent-card.json
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /.well-known/agent-card.json
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 30
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: gemini-a2a-server
|
||||||
|
labels:
|
||||||
|
app: gemini-a2a-server
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app: gemini-a2a-server
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 8080
|
||||||
|
protocol: TCP
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"@google-cloud/storage": "^7.16.0",
|
"@google-cloud/storage": "^7.16.0",
|
||||||
"@google/gemini-cli-core": "file:../core",
|
"@google/gemini-cli-core": "file:../core",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"google-auth-library": "^9.11.0",
|
||||||
"fs-extra": "^11.3.0",
|
"fs-extra": "^11.3.0",
|
||||||
"tar": "^7.5.2",
|
"tar": "^7.5.2",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder functions for A2UI standard catalog components.
|
||||||
|
* These create the component objects that go into updateComponents messages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { A2UIComponent } from './a2ui-extension.js';
|
||||||
|
|
||||||
|
// Layout components
|
||||||
|
|
||||||
|
export function column(
|
||||||
|
id: string,
|
||||||
|
children: string[],
|
||||||
|
opts?: { align?: string; justify?: string; weight?: number },
|
||||||
|
): A2UIComponent {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
component: 'Column',
|
||||||
|
children,
|
||||||
|
...opts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function row(
|
||||||
|
id: string,
|
||||||
|
children: string[],
|
||||||
|
opts?: { align?: string; justify?: string },
|
||||||
|
): A2UIComponent {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
component: 'Row',
|
||||||
|
children,
|
||||||
|
...opts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function card(
|
||||||
|
id: string,
|
||||||
|
child: string,
|
||||||
|
opts?: Record<string, unknown>,
|
||||||
|
): A2UIComponent {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
component: 'Card',
|
||||||
|
child,
|
||||||
|
...opts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content components
|
||||||
|
|
||||||
|
export function text(
|
||||||
|
id: string,
|
||||||
|
textContent: string | { path: string },
|
||||||
|
opts?: { variant?: string },
|
||||||
|
): A2UIComponent {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
component: 'Text',
|
||||||
|
text: textContent,
|
||||||
|
...opts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function icon(id: string, name: string): A2UIComponent {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
component: 'Icon',
|
||||||
|
name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function divider(
|
||||||
|
id: string,
|
||||||
|
axis: 'horizontal' | 'vertical' = 'horizontal',
|
||||||
|
): A2UIComponent {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
component: 'Divider',
|
||||||
|
axis,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive components
|
||||||
|
|
||||||
|
export function button(
|
||||||
|
id: string,
|
||||||
|
child: string,
|
||||||
|
action: {
|
||||||
|
event?: { name: string; context: Record<string, unknown> };
|
||||||
|
functionCall?: { call: string; args: Record<string, unknown> };
|
||||||
|
},
|
||||||
|
opts?: { variant?: 'primary' | 'borderless' },
|
||||||
|
): A2UIComponent {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
component: 'Button',
|
||||||
|
child,
|
||||||
|
action,
|
||||||
|
...opts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function textField(
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
valuePath: string,
|
||||||
|
opts?: {
|
||||||
|
variant?: 'shortText' | 'longText';
|
||||||
|
checks?: Array<{
|
||||||
|
call: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
message: string;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
): A2UIComponent {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
component: 'TextField',
|
||||||
|
label,
|
||||||
|
value: { path: valuePath },
|
||||||
|
...opts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkBox(
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
valuePath: string,
|
||||||
|
): A2UIComponent {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
component: 'CheckBox',
|
||||||
|
label,
|
||||||
|
value: { path: valuePath },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function choicePicker(
|
||||||
|
id: string,
|
||||||
|
options: Array<{ label: string; value: string }>,
|
||||||
|
valuePath: string,
|
||||||
|
opts?: { variant?: 'mutuallyExclusive' | 'multiSelect' },
|
||||||
|
): A2UIComponent {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
component: 'ChoicePicker',
|
||||||
|
options,
|
||||||
|
value: { path: valuePath },
|
||||||
|
...opts,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2UI (Agent-to-UI) Extension for A2A protocol.
|
||||||
|
* Implements the A2UI v0.10 specification for generating declarative UI
|
||||||
|
* messages that clients can render natively.
|
||||||
|
*
|
||||||
|
* @see https://a2ui.org/specification/v0_10/docs/a2ui_protocol.md
|
||||||
|
* @see https://a2ui.org/specification/v0_10/docs/a2ui_extension_specification.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Part } from '@a2a-js/sdk';
|
||||||
|
|
||||||
|
// Extension constants
|
||||||
|
export const A2UI_EXTENSION_URI = 'https://a2ui.org/a2a-extension/a2ui/v0.10';
|
||||||
|
export const A2UI_MIME_TYPE = 'application/json+a2ui';
|
||||||
|
export const A2UI_VERSION = 'v0.10';
|
||||||
|
export const STANDARD_CATALOG_ID =
|
||||||
|
'https://a2ui.org/specification/v0_10/standard_catalog.json';
|
||||||
|
|
||||||
|
// Metadata keys
|
||||||
|
export const MIME_TYPE_KEY = 'mimeType';
|
||||||
|
export const A2UI_CLIENT_CAPABILITIES_KEY = 'a2uiClientCapabilities';
|
||||||
|
export const A2UI_CLIENT_DATA_MODEL_KEY = 'a2uiClientDataModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2UI message types (server-to-client).
|
||||||
|
*/
|
||||||
|
export interface CreateSurfaceMessage {
|
||||||
|
version: typeof A2UI_VERSION;
|
||||||
|
createSurface: {
|
||||||
|
surfaceId: string;
|
||||||
|
catalogId: string;
|
||||||
|
theme?: Record<string, unknown>;
|
||||||
|
sendDataModel?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateComponentsMessage {
|
||||||
|
version: typeof A2UI_VERSION;
|
||||||
|
updateComponents: {
|
||||||
|
surfaceId: string;
|
||||||
|
components: A2UIComponent[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateDataModelMessage {
|
||||||
|
version: typeof A2UI_VERSION;
|
||||||
|
updateDataModel: {
|
||||||
|
surfaceId: string;
|
||||||
|
path?: string;
|
||||||
|
value?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteSurfaceMessage {
|
||||||
|
version: typeof A2UI_VERSION;
|
||||||
|
deleteSurface: {
|
||||||
|
surfaceId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type A2UIServerMessage =
|
||||||
|
| CreateSurfaceMessage
|
||||||
|
| UpdateComponentsMessage
|
||||||
|
| UpdateDataModelMessage
|
||||||
|
| DeleteSurfaceMessage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2UI component definition.
|
||||||
|
*/
|
||||||
|
export interface A2UIComponent {
|
||||||
|
id: string;
|
||||||
|
component: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2UI client-to-server action message.
|
||||||
|
*/
|
||||||
|
export interface A2UIActionMessage {
|
||||||
|
version: typeof A2UI_VERSION;
|
||||||
|
action: {
|
||||||
|
name: string;
|
||||||
|
surfaceId: string;
|
||||||
|
sourceComponentId: string;
|
||||||
|
timestamp: string;
|
||||||
|
context: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2UI client capabilities sent in metadata.
|
||||||
|
*/
|
||||||
|
export interface A2UIClientCapabilities {
|
||||||
|
supportedCatalogIds: string[];
|
||||||
|
inlineCatalogs?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an A2A DataPart containing A2UI messages.
|
||||||
|
* Per the spec, the data field contains an ARRAY of A2UI messages.
|
||||||
|
*/
|
||||||
|
export function createA2UIPart(messages: A2UIServerMessage[]): Part {
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'data',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
data: messages as unknown as Record<string, unknown>,
|
||||||
|
metadata: {
|
||||||
|
[MIME_TYPE_KEY]: A2UI_MIME_TYPE,
|
||||||
|
},
|
||||||
|
} as Part;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a single A2A DataPart from one A2UI message.
|
||||||
|
*/
|
||||||
|
export function createA2UISinglePart(message: A2UIServerMessage): Part {
|
||||||
|
return createA2UIPart([message]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an A2A Part contains A2UI data.
|
||||||
|
*/
|
||||||
|
export function isA2UIPart(part: Part): boolean {
|
||||||
|
return (
|
||||||
|
part.kind === 'data' &&
|
||||||
|
part.metadata != null &&
|
||||||
|
part.metadata[MIME_TYPE_KEY] === A2UI_MIME_TYPE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts A2UI action messages from an A2A Part.
|
||||||
|
*/
|
||||||
|
export function extractA2UIActions(part: Part): A2UIActionMessage[] {
|
||||||
|
if (!isA2UIPart(part)) return [];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
const data = (part as unknown as { data?: unknown[] }).data;
|
||||||
|
if (!Array.isArray(data)) return [];
|
||||||
|
return data.filter(
|
||||||
|
(msg): msg is A2UIActionMessage =>
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
'action' in msg &&
|
||||||
|
'version' in msg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the A2UI AgentExtension configuration for the AgentCard.
|
||||||
|
*/
|
||||||
|
export function getA2UIAgentExtension(
|
||||||
|
supportedCatalogIds: string[] = [STANDARD_CATALOG_ID],
|
||||||
|
acceptsInlineCatalogs = false,
|
||||||
|
): {
|
||||||
|
uri: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
} {
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
if (supportedCatalogIds.length > 0) {
|
||||||
|
params['supportedCatalogIds'] = supportedCatalogIds;
|
||||||
|
}
|
||||||
|
if (acceptsInlineCatalogs) {
|
||||||
|
params['acceptsInlineCatalogs'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uri: A2UI_EXTENSION_URI,
|
||||||
|
description: 'Provides agent driven UI using the A2UI JSON format.',
|
||||||
|
required: false,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the A2UI extension was requested via extension headers or message.
|
||||||
|
*/
|
||||||
|
export function isA2UIRequested(
|
||||||
|
requestedExtensions?: string[],
|
||||||
|
messageExtensions?: string[],
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
(requestedExtensions?.includes(A2UI_EXTENSION_URI) ?? false) ||
|
||||||
|
(messageExtensions?.includes(A2UI_EXTENSION_URI) ?? false)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages A2UI surfaces for the Gemini CLI A2A server.
|
||||||
|
* Creates and updates surfaces for:
|
||||||
|
* - Tool call approval UIs
|
||||||
|
* - Agent text/thought streaming displays
|
||||||
|
* - Task status indicators
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Part } from '@a2a-js/sdk';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import {
|
||||||
|
A2UI_VERSION,
|
||||||
|
STANDARD_CATALOG_ID,
|
||||||
|
createA2UIPart,
|
||||||
|
type A2UIServerMessage,
|
||||||
|
type A2UIComponent,
|
||||||
|
} from './a2ui-extension.js';
|
||||||
|
import {
|
||||||
|
column,
|
||||||
|
row,
|
||||||
|
text,
|
||||||
|
button,
|
||||||
|
card,
|
||||||
|
icon,
|
||||||
|
divider,
|
||||||
|
} from './a2ui-components.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates A2UI parts for tool call approval surfaces.
|
||||||
|
*/
|
||||||
|
export function createToolCallApprovalSurface(
|
||||||
|
taskId: string,
|
||||||
|
toolCall: {
|
||||||
|
callId: string;
|
||||||
|
name: string;
|
||||||
|
displayName?: string;
|
||||||
|
description?: string;
|
||||||
|
args?: Record<string, unknown>;
|
||||||
|
kind?: string;
|
||||||
|
},
|
||||||
|
): Part {
|
||||||
|
const surfaceId = `tool_approval_${taskId}_${toolCall.callId}`;
|
||||||
|
const toolDisplayName = toolCall.displayName || toolCall.name;
|
||||||
|
const argsPreview = toolCall.args
|
||||||
|
? JSON.stringify(toolCall.args, null, 2).substring(0, 500)
|
||||||
|
: 'No arguments';
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[A2UI] Creating tool approval surface: ${surfaceId} for tool: ${toolDisplayName}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const messages: A2UIServerMessage[] = [
|
||||||
|
// 1. Create the surface
|
||||||
|
{
|
||||||
|
version: A2UI_VERSION,
|
||||||
|
createSurface: {
|
||||||
|
surfaceId,
|
||||||
|
catalogId: STANDARD_CATALOG_ID,
|
||||||
|
theme: {
|
||||||
|
primaryColor: '#1a73e8',
|
||||||
|
agentDisplayName: 'Gemini CLI Agent',
|
||||||
|
},
|
||||||
|
sendDataModel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 2. Define the components
|
||||||
|
{
|
||||||
|
version: A2UI_VERSION,
|
||||||
|
updateComponents: {
|
||||||
|
surfaceId,
|
||||||
|
components: buildToolApprovalComponents(
|
||||||
|
taskId,
|
||||||
|
toolCall.callId,
|
||||||
|
toolDisplayName,
|
||||||
|
toolCall.description || '',
|
||||||
|
argsPreview,
|
||||||
|
toolCall.kind || 'tool',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 3. Populate the data model
|
||||||
|
{
|
||||||
|
version: A2UI_VERSION,
|
||||||
|
updateDataModel: {
|
||||||
|
surfaceId,
|
||||||
|
value: {
|
||||||
|
tool: {
|
||||||
|
callId: toolCall.callId,
|
||||||
|
name: toolCall.name,
|
||||||
|
displayName: toolDisplayName,
|
||||||
|
description: toolCall.description || '',
|
||||||
|
args: argsPreview,
|
||||||
|
kind: toolCall.kind || 'tool',
|
||||||
|
status: 'awaiting_approval',
|
||||||
|
},
|
||||||
|
taskId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return createA2UIPart(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToolApprovalComponents(
|
||||||
|
taskId: string,
|
||||||
|
callId: string,
|
||||||
|
toolName: string,
|
||||||
|
description: string,
|
||||||
|
argsPreview: string,
|
||||||
|
kind: string,
|
||||||
|
): A2UIComponent[] {
|
||||||
|
return [
|
||||||
|
// Root card
|
||||||
|
card('root', 'main_column'),
|
||||||
|
|
||||||
|
// Main vertical layout
|
||||||
|
column(
|
||||||
|
'main_column',
|
||||||
|
[
|
||||||
|
'header_row',
|
||||||
|
'description_text',
|
||||||
|
'divider_1',
|
||||||
|
'args_label',
|
||||||
|
'args_text',
|
||||||
|
'divider_2',
|
||||||
|
'action_row',
|
||||||
|
],
|
||||||
|
{ align: 'stretch' },
|
||||||
|
),
|
||||||
|
|
||||||
|
// Header with icon and tool name
|
||||||
|
row('header_row', ['tool_icon', 'tool_name_text'], {
|
||||||
|
align: 'center',
|
||||||
|
}),
|
||||||
|
icon('tool_icon', kind === 'shell' ? 'terminal' : 'build'),
|
||||||
|
text('tool_name_text', `**${toolName}** requires approval`, {
|
||||||
|
variant: 'h3',
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Description
|
||||||
|
text(
|
||||||
|
'description_text',
|
||||||
|
description || 'This tool needs your permission to execute.',
|
||||||
|
),
|
||||||
|
|
||||||
|
divider('divider_1'),
|
||||||
|
|
||||||
|
// Arguments preview
|
||||||
|
text('args_label', '**Arguments:**', { variant: 'caption' }),
|
||||||
|
text('args_text', `\`\`\`\n${argsPreview}\n\`\`\``),
|
||||||
|
|
||||||
|
divider('divider_2'),
|
||||||
|
|
||||||
|
// Action buttons row
|
||||||
|
row(
|
||||||
|
'action_row',
|
||||||
|
['approve_button', 'approve_always_button', 'reject_button'],
|
||||||
|
{ justify: 'spaceBetween' },
|
||||||
|
),
|
||||||
|
|
||||||
|
// Approve button
|
||||||
|
text('approve_label', 'Approve'),
|
||||||
|
button(
|
||||||
|
'approve_button',
|
||||||
|
'approve_label',
|
||||||
|
{
|
||||||
|
event: {
|
||||||
|
name: 'tool_confirmation',
|
||||||
|
context: {
|
||||||
|
taskId,
|
||||||
|
callId,
|
||||||
|
outcome: 'proceed_once',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ variant: 'primary' },
|
||||||
|
),
|
||||||
|
|
||||||
|
// Approve always button
|
||||||
|
text('approve_always_label', 'Always Allow'),
|
||||||
|
button('approve_always_button', 'approve_always_label', {
|
||||||
|
event: {
|
||||||
|
name: 'tool_confirmation',
|
||||||
|
context: {
|
||||||
|
taskId,
|
||||||
|
callId,
|
||||||
|
outcome: 'proceed_always_tool',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Reject button
|
||||||
|
text('reject_label', 'Reject'),
|
||||||
|
button('reject_button', 'reject_label', {
|
||||||
|
event: {
|
||||||
|
name: 'tool_confirmation',
|
||||||
|
context: {
|
||||||
|
taskId,
|
||||||
|
callId,
|
||||||
|
outcome: 'cancel',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an A2UI surface update for tool execution status.
|
||||||
|
*/
|
||||||
|
export function updateToolCallStatus(
|
||||||
|
taskId: string,
|
||||||
|
callId: string,
|
||||||
|
status: string,
|
||||||
|
output?: string,
|
||||||
|
): Part {
|
||||||
|
const surfaceId = `tool_approval_${taskId}_${callId}`;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[A2UI] Updating tool status surface: ${surfaceId} status: ${status}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const messages: A2UIServerMessage[] = [
|
||||||
|
{
|
||||||
|
version: A2UI_VERSION,
|
||||||
|
updateDataModel: {
|
||||||
|
surfaceId,
|
||||||
|
path: '/tool/status',
|
||||||
|
value: status,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// If tool completed, update the UI to show result
|
||||||
|
if (['success', 'error', 'cancelled'].includes(status)) {
|
||||||
|
messages.push({
|
||||||
|
version: A2UI_VERSION,
|
||||||
|
updateComponents: {
|
||||||
|
surfaceId,
|
||||||
|
components: [
|
||||||
|
// Replace action row with status indicator
|
||||||
|
row('action_row', ['status_icon', 'status_text'], {
|
||||||
|
align: 'center',
|
||||||
|
}),
|
||||||
|
icon(
|
||||||
|
'status_icon',
|
||||||
|
status === 'success'
|
||||||
|
? 'check_circle'
|
||||||
|
: status === 'error'
|
||||||
|
? 'error'
|
||||||
|
: 'cancel',
|
||||||
|
),
|
||||||
|
text(
|
||||||
|
'status_text',
|
||||||
|
status === 'success'
|
||||||
|
? 'Tool executed successfully'
|
||||||
|
: status === 'error'
|
||||||
|
? 'Tool execution failed'
|
||||||
|
: 'Tool execution cancelled',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (output) {
|
||||||
|
messages.push({
|
||||||
|
version: A2UI_VERSION,
|
||||||
|
updateDataModel: {
|
||||||
|
surfaceId,
|
||||||
|
path: '/tool/output',
|
||||||
|
value: output,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createA2UIPart(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an A2UI text content surface for agent messages.
|
||||||
|
*/
|
||||||
|
export function createTextContentPart(
|
||||||
|
taskId: string,
|
||||||
|
content: string,
|
||||||
|
surfaceId?: string,
|
||||||
|
): Part {
|
||||||
|
const sid = surfaceId || `agent_text_${taskId}`;
|
||||||
|
|
||||||
|
const messages: A2UIServerMessage[] = [
|
||||||
|
{
|
||||||
|
version: A2UI_VERSION,
|
||||||
|
updateDataModel: {
|
||||||
|
surfaceId: sid,
|
||||||
|
path: '/content/text',
|
||||||
|
value: content,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return createA2UIPart(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the initial agent response surface.
|
||||||
|
*/
|
||||||
|
export function createAgentResponseSurface(taskId: string): Part {
|
||||||
|
const surfaceId = `agent_response_${taskId}`;
|
||||||
|
|
||||||
|
logger.info(`[A2UI] Creating agent response surface: ${surfaceId}`);
|
||||||
|
|
||||||
|
const messages: A2UIServerMessage[] = [
|
||||||
|
{
|
||||||
|
version: A2UI_VERSION,
|
||||||
|
createSurface: {
|
||||||
|
surfaceId,
|
||||||
|
catalogId: STANDARD_CATALOG_ID,
|
||||||
|
theme: {
|
||||||
|
primaryColor: '#1a73e8',
|
||||||
|
agentDisplayName: 'Gemini CLI Agent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: A2UI_VERSION,
|
||||||
|
updateComponents: {
|
||||||
|
surfaceId,
|
||||||
|
components: [
|
||||||
|
card('root', 'response_column'),
|
||||||
|
column('response_column', ['response_text', 'status_text'], {
|
||||||
|
align: 'stretch',
|
||||||
|
}),
|
||||||
|
text('response_text', { path: '/response/text' }),
|
||||||
|
text(
|
||||||
|
'status_text',
|
||||||
|
{ path: '/response/status' },
|
||||||
|
{
|
||||||
|
variant: 'caption',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: A2UI_VERSION,
|
||||||
|
updateDataModel: {
|
||||||
|
surfaceId,
|
||||||
|
value: {
|
||||||
|
response: {
|
||||||
|
text: '',
|
||||||
|
status: 'Working...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return createA2UIPart(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the agent response surface with new text content.
|
||||||
|
*/
|
||||||
|
export function updateAgentResponseText(
|
||||||
|
taskId: string,
|
||||||
|
content: string,
|
||||||
|
status?: string,
|
||||||
|
): Part {
|
||||||
|
const surfaceId = `agent_response_${taskId}`;
|
||||||
|
|
||||||
|
const messages: A2UIServerMessage[] = [
|
||||||
|
{
|
||||||
|
version: A2UI_VERSION,
|
||||||
|
updateDataModel: {
|
||||||
|
surfaceId,
|
||||||
|
path: '/response/text',
|
||||||
|
value: content,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
messages.push({
|
||||||
|
version: A2UI_VERSION,
|
||||||
|
updateDataModel: {
|
||||||
|
surfaceId,
|
||||||
|
path: '/response/status',
|
||||||
|
value: status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return createA2UIPart(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an A2UI thought surface.
|
||||||
|
*/
|
||||||
|
export function createThoughtPart(
|
||||||
|
taskId: string,
|
||||||
|
subject: string,
|
||||||
|
description: string,
|
||||||
|
): Part {
|
||||||
|
const surfaceId = `thought_${taskId}_${Date.now()}`;
|
||||||
|
|
||||||
|
const messages: A2UIServerMessage[] = [
|
||||||
|
{
|
||||||
|
version: A2UI_VERSION,
|
||||||
|
createSurface: {
|
||||||
|
surfaceId,
|
||||||
|
catalogId: STANDARD_CATALOG_ID,
|
||||||
|
theme: {
|
||||||
|
primaryColor: '#7c4dff',
|
||||||
|
agentDisplayName: 'Gemini CLI Agent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: A2UI_VERSION,
|
||||||
|
updateComponents: {
|
||||||
|
surfaceId,
|
||||||
|
components: [
|
||||||
|
card('root', 'thought_column'),
|
||||||
|
column('thought_column', ['thought_icon_row', 'thought_desc'], {
|
||||||
|
align: 'stretch',
|
||||||
|
}),
|
||||||
|
row('thought_icon_row', ['thought_icon', 'thought_subject'], {
|
||||||
|
align: 'center',
|
||||||
|
}),
|
||||||
|
icon('thought_icon', 'psychology'),
|
||||||
|
text('thought_subject', `*${subject}*`, { variant: 'h4' }),
|
||||||
|
text('thought_desc', description),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return createA2UIPart(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a tool approval surface after resolution.
|
||||||
|
*/
|
||||||
|
export function deleteToolApprovalSurface(
|
||||||
|
taskId: string,
|
||||||
|
callId: string,
|
||||||
|
): Part {
|
||||||
|
const surfaceId = `tool_approval_${taskId}_${callId}`;
|
||||||
|
|
||||||
|
logger.info(`[A2UI] Deleting tool approval surface: ${surfaceId}`);
|
||||||
|
|
||||||
|
const messages: A2UIServerMessage[] = [
|
||||||
|
{
|
||||||
|
version: A2UI_VERSION,
|
||||||
|
deleteSurface: {
|
||||||
|
surfaceId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return createA2UIPart(messages);
|
||||||
|
}
|
||||||
@@ -36,6 +36,10 @@ import { loadExtensions } from '../config/extension.js';
|
|||||||
import { Task } from './task.js';
|
import { Task } from './task.js';
|
||||||
import { requestStorage } from '../http/requestStorage.js';
|
import { requestStorage } from '../http/requestStorage.js';
|
||||||
import { pushTaskStateFailed } from '../utils/executor_utils.js';
|
import { pushTaskStateFailed } from '../utils/executor_utils.js';
|
||||||
|
import {
|
||||||
|
A2UI_CLIENT_CAPABILITIES_KEY,
|
||||||
|
A2UI_EXTENSION_URI,
|
||||||
|
} from '../a2ui/a2ui-extension.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides a wrapper for Task. Passes data from Task to SDKTask.
|
* Provides a wrapper for Task. Passes data from Task to SDKTask.
|
||||||
@@ -73,6 +77,24 @@ class TaskWrapper {
|
|||||||
artifacts: [],
|
artifacts: [],
|
||||||
};
|
};
|
||||||
sdkTask.metadata!['_contextId'] = this.task.contextId;
|
sdkTask.metadata!['_contextId'] = this.task.contextId;
|
||||||
|
|
||||||
|
// Persist conversation history for session resumability.
|
||||||
|
// GCSTaskStore saves this as a separate object and restores it on load.
|
||||||
|
try {
|
||||||
|
const conversationHistory = this.task.geminiClient.getHistory();
|
||||||
|
if (conversationHistory.length > 0) {
|
||||||
|
sdkTask.metadata!['_conversationHistory'] = conversationHistory;
|
||||||
|
logger.info(
|
||||||
|
`Task ${this.task.id}: Persisting ${conversationHistory.length} conversation history entries.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// GeminiClient may not be initialized yet
|
||||||
|
logger.warn(
|
||||||
|
`Task ${this.task.id}: Could not get conversation history for persistence.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return sdkTask;
|
return sdkTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,7 +149,25 @@ export class CoderAgentExecutor implements AgentExecutor {
|
|||||||
agentSettings.autoExecute,
|
agentSettings.autoExecute,
|
||||||
);
|
);
|
||||||
runtimeTask.taskState = persistedState._taskState;
|
runtimeTask.taskState = persistedState._taskState;
|
||||||
await runtimeTask.geminiClient.initialize();
|
|
||||||
|
// Restore conversation history if available from the TaskStore.
|
||||||
|
// This enables session resumability — the LLM gets full context of
|
||||||
|
// prior interactions rather than starting with a blank slate.
|
||||||
|
const conversationHistory = metadata['_conversationHistory'];
|
||||||
|
if (Array.isArray(conversationHistory) && conversationHistory.length > 0) {
|
||||||
|
logger.info(
|
||||||
|
`Task ${sdkTask.id}: Resuming with ${conversationHistory.length} conversation history entries.`,
|
||||||
|
);
|
||||||
|
// History was serialized from GeminiClient.getHistory() which returns
|
||||||
|
// Content[]. After JSON round-trip it's structurally identical.
|
||||||
|
await runtimeTask.geminiClient.initialize();
|
||||||
|
runtimeTask.geminiClient.setHistory(
|
||||||
|
|
||||||
|
conversationHistory,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await runtimeTask.geminiClient.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
const wrapper = new TaskWrapper(runtimeTask, agentSettings);
|
const wrapper = new TaskWrapper(runtimeTask, agentSettings);
|
||||||
this.tasks.set(sdkTask.id, wrapper);
|
this.tasks.set(sdkTask.id, wrapper);
|
||||||
@@ -435,6 +475,22 @@ export class CoderAgentExecutor implements AgentExecutor {
|
|||||||
|
|
||||||
const currentTask = wrapper.task;
|
const currentTask = wrapper.task;
|
||||||
|
|
||||||
|
// Detect A2UI extension activation from the request
|
||||||
|
// Check if user message metadata contains A2UI client capabilities
|
||||||
|
// or if the extensions header includes the A2UI URI
|
||||||
|
const messageMetadata = userMessage.metadata;
|
||||||
|
const hasA2UICapabilities =
|
||||||
|
messageMetadata?.[A2UI_CLIENT_CAPABILITIES_KEY] != null;
|
||||||
|
// Also check if extension URI is referenced in message extensions
|
||||||
|
const messageExtensions = messageMetadata?.['extensions'];
|
||||||
|
const hasA2UIExtension =
|
||||||
|
Array.isArray(messageExtensions) &&
|
||||||
|
messageExtensions.includes(A2UI_EXTENSION_URI);
|
||||||
|
if (hasA2UICapabilities || hasA2UIExtension) {
|
||||||
|
currentTask.a2uiEnabled = true;
|
||||||
|
logger.info(`[CoderAgentExecutor] A2UI enabled for task ${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (['canceled', 'failed', 'completed'].includes(currentTask.taskState)) {
|
if (['canceled', 'failed', 'completed'].includes(currentTask.taskState)) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[CoderAgentExecutor] Attempted to execute task ${taskId} which is already in state ${currentTask.taskState}. Ignoring.`,
|
`[CoderAgentExecutor] Attempted to execute task ${taskId} which is already in state ${currentTask.taskState}. Ignoring.`,
|
||||||
@@ -552,6 +608,9 @@ export class CoderAgentExecutor implements AgentExecutor {
|
|||||||
logger.info(
|
logger.info(
|
||||||
`[CoderAgentExecutor] Task ${taskId}: Agent turn finished, setting to input-required.`,
|
`[CoderAgentExecutor] Task ${taskId}: Agent turn finished, setting to input-required.`,
|
||||||
);
|
);
|
||||||
|
// Finalize A2UI surfaces before marking complete
|
||||||
|
currentTask.finalizeA2UISurfaces();
|
||||||
|
|
||||||
const stateChange: StateChange = {
|
const stateChange: StateChange = {
|
||||||
kind: CoderAgentEvent.StateChangeEvent,
|
kind: CoderAgentEvent.StateChangeEvent,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ import type {
|
|||||||
Citation,
|
Citation,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import type { PartUnion, Part as genAiPart } from '@google/genai';
|
import type { PartUnion, Part as genAiPart } from '@google/genai';
|
||||||
|
import {
|
||||||
|
createToolCallApprovalSurface,
|
||||||
|
updateToolCallStatus,
|
||||||
|
createAgentResponseSurface,
|
||||||
|
updateAgentResponseText,
|
||||||
|
createThoughtPart as createA2UIThoughtPart,
|
||||||
|
deleteToolApprovalSurface,
|
||||||
|
} from '../a2ui/a2ui-surface-manager.js';
|
||||||
|
import { isA2UIPart, extractA2UIActions } from '../a2ui/a2ui-extension.js';
|
||||||
|
|
||||||
type UnionKeys<T> = T extends T ? keyof T : never;
|
type UnionKeys<T> = T extends T ? keyof T : never;
|
||||||
|
|
||||||
@@ -75,6 +84,11 @@ export class Task {
|
|||||||
promptCount = 0;
|
promptCount = 0;
|
||||||
autoExecute: boolean;
|
autoExecute: boolean;
|
||||||
|
|
||||||
|
// A2UI support
|
||||||
|
a2uiEnabled = false;
|
||||||
|
private accumulatedText = '';
|
||||||
|
private a2uiResponseSurfaceCreated = false;
|
||||||
|
|
||||||
// For tool waiting logic
|
// For tool waiting logic
|
||||||
private pendingToolCalls: Map<string, string> = new Map(); //toolCallId --> status
|
private pendingToolCalls: Map<string, string> = new Map(); //toolCallId --> status
|
||||||
private toolCompletionPromise?: Promise<void>;
|
private toolCompletionPromise?: Promise<void>;
|
||||||
@@ -391,6 +405,44 @@ export class Task {
|
|||||||
: { kind: CoderAgentEvent.ToolCallUpdateEvent };
|
: { kind: CoderAgentEvent.ToolCallUpdateEvent };
|
||||||
const message = this.toolStatusMessage(tc, this.id, this.contextId);
|
const message = this.toolStatusMessage(tc, this.id, this.contextId);
|
||||||
|
|
||||||
|
// Add A2UI parts for tool call updates if A2UI is enabled
|
||||||
|
if (this.a2uiEnabled) {
|
||||||
|
try {
|
||||||
|
if (tc.status === 'awaiting_approval') {
|
||||||
|
const a2uiPart = createToolCallApprovalSurface(this.id, {
|
||||||
|
callId: tc.request.callId,
|
||||||
|
name: tc.request.name,
|
||||||
|
displayName: tc.tool?.displayName || tc.tool?.name,
|
||||||
|
description: tc.tool?.description,
|
||||||
|
args: tc.request.args as Record<string, unknown> | undefined,
|
||||||
|
kind: tc.tool?.kind,
|
||||||
|
});
|
||||||
|
message.parts.push(a2uiPart);
|
||||||
|
logger.info(
|
||||||
|
`[Task] A2UI: Added tool approval surface for ${tc.request.callId}`,
|
||||||
|
);
|
||||||
|
} else if (['success', 'error', 'cancelled'].includes(tc.status)) {
|
||||||
|
const output =
|
||||||
|
'liveOutput' in tc ? String(tc.liveOutput) : undefined;
|
||||||
|
const a2uiPart = updateToolCallStatus(
|
||||||
|
this.id,
|
||||||
|
tc.request.callId,
|
||||||
|
tc.status,
|
||||||
|
output,
|
||||||
|
);
|
||||||
|
message.parts.push(a2uiPart);
|
||||||
|
logger.info(
|
||||||
|
`[Task] A2UI: Updated tool status for ${tc.request.callId}: ${tc.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (a2uiError) {
|
||||||
|
logger.error(
|
||||||
|
'[Task] A2UI: Error generating tool call surface:',
|
||||||
|
a2uiError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const event = this._createStatusUpdateEvent(
|
const event = this._createStatusUpdateEvent(
|
||||||
this.taskState,
|
this.taskState,
|
||||||
coderAgentMessage,
|
coderAgentMessage,
|
||||||
@@ -954,7 +1006,66 @@ export class Task {
|
|||||||
let anyConfirmationHandled = false;
|
let anyConfirmationHandled = false;
|
||||||
let hasContentForLlm = false;
|
let hasContentForLlm = false;
|
||||||
|
|
||||||
|
// Reset A2UI accumulated text for new user turn
|
||||||
|
if (this.a2uiEnabled) {
|
||||||
|
this.accumulatedText = '';
|
||||||
|
this.a2uiResponseSurfaceCreated = false;
|
||||||
|
}
|
||||||
|
|
||||||
for (const part of userMessage.parts) {
|
for (const part of userMessage.parts) {
|
||||||
|
// Handle A2UI action messages (e.g., button clicks for tool approval)
|
||||||
|
if (this.a2uiEnabled && isA2UIPart(part)) {
|
||||||
|
const actions = extractA2UIActions(part);
|
||||||
|
for (const action of actions) {
|
||||||
|
if (action.action.name === 'tool_confirmation') {
|
||||||
|
const ctx = action.action.context;
|
||||||
|
// Convert A2UI action to a tool confirmation data part
|
||||||
|
const syntheticPart: Part = {
|
||||||
|
kind: 'data',
|
||||||
|
data: {
|
||||||
|
callId: ctx['callId'],
|
||||||
|
outcome: ctx['outcome'],
|
||||||
|
},
|
||||||
|
} as Part;
|
||||||
|
const handled =
|
||||||
|
await this._handleToolConfirmationPart(syntheticPart);
|
||||||
|
if (handled) {
|
||||||
|
anyConfirmationHandled = true;
|
||||||
|
// Emit a delete surface part for the approval UI
|
||||||
|
try {
|
||||||
|
const deletePart = deleteToolApprovalSurface(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
(ctx['taskId'] as string) || this.id,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
ctx['callId'] as string,
|
||||||
|
);
|
||||||
|
const deleteMessage: Message = {
|
||||||
|
kind: 'message',
|
||||||
|
role: 'agent',
|
||||||
|
parts: [deletePart],
|
||||||
|
messageId: uuidv4(),
|
||||||
|
taskId: this.id,
|
||||||
|
contextId: this.contextId,
|
||||||
|
};
|
||||||
|
const event = this._createStatusUpdateEvent(
|
||||||
|
this.taskState,
|
||||||
|
{ kind: CoderAgentEvent.ToolCallUpdateEvent },
|
||||||
|
deleteMessage,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
this.eventBus?.publish(event);
|
||||||
|
} catch (a2uiError) {
|
||||||
|
logger.error(
|
||||||
|
'[Task] A2UI: Error deleting approval surface:',
|
||||||
|
a2uiError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const confirmationHandled = await this._handleToolConfirmationPart(part);
|
const confirmationHandled = await this._handleToolConfirmationPart(part);
|
||||||
if (confirmationHandled) {
|
if (confirmationHandled) {
|
||||||
anyConfirmationHandled = true;
|
anyConfirmationHandled = true;
|
||||||
@@ -1020,6 +1131,33 @@ export class Task {
|
|||||||
}
|
}
|
||||||
logger.info('[Task] Sending text content to event bus.');
|
logger.info('[Task] Sending text content to event bus.');
|
||||||
const message = this._createTextMessage(content);
|
const message = this._createTextMessage(content);
|
||||||
|
|
||||||
|
// Add A2UI response surface parts if A2UI is enabled
|
||||||
|
if (this.a2uiEnabled) {
|
||||||
|
try {
|
||||||
|
this.accumulatedText += content;
|
||||||
|
if (!this.a2uiResponseSurfaceCreated) {
|
||||||
|
const surfacePart = createAgentResponseSurface(this.id);
|
||||||
|
message.parts.push(surfacePart);
|
||||||
|
this.a2uiResponseSurfaceCreated = true;
|
||||||
|
logger.info(
|
||||||
|
`[Task] A2UI: Created agent response surface for task ${this.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const updatePart = updateAgentResponseText(
|
||||||
|
this.id,
|
||||||
|
this.accumulatedText,
|
||||||
|
'Working...',
|
||||||
|
);
|
||||||
|
message.parts.push(updatePart);
|
||||||
|
} catch (a2uiError) {
|
||||||
|
logger.error(
|
||||||
|
'[Task] A2UI: Error generating text content surface:',
|
||||||
|
a2uiError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const textContent: TextContent = {
|
const textContent: TextContent = {
|
||||||
kind: CoderAgentEvent.TextContentEvent,
|
kind: CoderAgentEvent.TextContentEvent,
|
||||||
};
|
};
|
||||||
@@ -1041,15 +1179,35 @@ export class Task {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logger.info('[Task] Sending thought to event bus.');
|
logger.info('[Task] Sending thought to event bus.');
|
||||||
|
const parts: Part[] = [
|
||||||
|
{
|
||||||
|
kind: 'data',
|
||||||
|
data: content,
|
||||||
|
} as Part,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add A2UI thought surface if A2UI is enabled
|
||||||
|
if (this.a2uiEnabled) {
|
||||||
|
try {
|
||||||
|
const a2uiPart = createA2UIThoughtPart(
|
||||||
|
this.id,
|
||||||
|
content.subject || 'Thinking...',
|
||||||
|
content.description || '',
|
||||||
|
);
|
||||||
|
parts.push(a2uiPart);
|
||||||
|
logger.info(`[Task] A2UI: Added thought surface for task ${this.id}`);
|
||||||
|
} catch (a2uiError) {
|
||||||
|
logger.error(
|
||||||
|
'[Task] A2UI: Error generating thought surface:',
|
||||||
|
a2uiError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const message: Message = {
|
const message: Message = {
|
||||||
kind: 'message',
|
kind: 'message',
|
||||||
role: 'agent',
|
role: 'agent',
|
||||||
parts: [
|
parts,
|
||||||
{
|
|
||||||
kind: 'data',
|
|
||||||
data: content,
|
|
||||||
} as Part,
|
|
||||||
],
|
|
||||||
messageId: uuidv4(),
|
messageId: uuidv4(),
|
||||||
taskId: this.id,
|
taskId: this.id,
|
||||||
contextId: this.contextId,
|
contextId: this.contextId,
|
||||||
@@ -1070,6 +1228,43 @@ export class Task {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalizes A2UI surfaces when the agent turn is complete.
|
||||||
|
* Updates the response surface status to "Done".
|
||||||
|
*/
|
||||||
|
finalizeA2UISurfaces(): void {
|
||||||
|
if (!this.a2uiEnabled || !this.a2uiResponseSurfaceCreated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const finalPart = updateAgentResponseText(
|
||||||
|
this.id,
|
||||||
|
this.accumulatedText,
|
||||||
|
'Done',
|
||||||
|
);
|
||||||
|
const message: Message = {
|
||||||
|
kind: 'message',
|
||||||
|
role: 'agent',
|
||||||
|
parts: [finalPart],
|
||||||
|
messageId: uuidv4(),
|
||||||
|
taskId: this.id,
|
||||||
|
contextId: this.contextId,
|
||||||
|
};
|
||||||
|
const event = this._createStatusUpdateEvent(
|
||||||
|
this.taskState,
|
||||||
|
{ kind: CoderAgentEvent.TextContentEvent },
|
||||||
|
message,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
this.eventBus?.publish(event);
|
||||||
|
logger.info(
|
||||||
|
`[Task] A2UI: Finalized response surface for task ${this.id}`,
|
||||||
|
);
|
||||||
|
} catch (a2uiError) {
|
||||||
|
logger.error('[Task] A2UI: Error finalizing surfaces:', a2uiError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_sendCitation(citation: string) {
|
_sendCitation(citation: string) {
|
||||||
if (!citation || citation.trim() === '') {
|
if (!citation || citation.trim() === '') {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2A client wrapper for the Google Chat bridge.
|
||||||
|
* Connects to the A2A server (local or remote) and sends/receives messages.
|
||||||
|
* Follows the patterns from core/agents/a2a-client-manager.ts and
|
||||||
|
* core/agents/remote-invocation.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Message, Task, Part, MessageSendParams } from '@a2a-js/sdk';
|
||||||
|
import {
|
||||||
|
type Client,
|
||||||
|
ClientFactory,
|
||||||
|
ClientFactoryOptions,
|
||||||
|
DefaultAgentCardResolver,
|
||||||
|
RestTransportFactory,
|
||||||
|
JsonRpcTransportFactory,
|
||||||
|
} from '@a2a-js/sdk/client';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { A2UI_EXTENSION_URI, A2UI_MIME_TYPE } from '../a2ui/a2ui-extension.js';
|
||||||
|
|
||||||
|
export type A2AResponse = Message | Task;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts contextId and taskId from an A2A response.
|
||||||
|
* Follows extractIdsFromResponse pattern from a2aUtils.ts.
|
||||||
|
*/
|
||||||
|
export function extractIdsFromResponse(result: A2AResponse): {
|
||||||
|
contextId?: string;
|
||||||
|
taskId?: string;
|
||||||
|
} {
|
||||||
|
if (result.kind === 'message') {
|
||||||
|
return {
|
||||||
|
contextId: result.contextId,
|
||||||
|
taskId: result.taskId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.kind === 'task') {
|
||||||
|
const contextId = result.contextId;
|
||||||
|
let taskId: string | undefined = result.id;
|
||||||
|
|
||||||
|
// Clear taskId on terminal states so next interaction starts a fresh task
|
||||||
|
const state = result.status?.state;
|
||||||
|
if (state === 'completed' || state === 'failed' || state === 'canceled') {
|
||||||
|
taskId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { contextId, taskId };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts all parts from an A2A response.
|
||||||
|
* For Tasks, checks history (accumulated from intermediate status-update events),
|
||||||
|
* the final status message, and artifacts. The blocking DefaultRequestHandler
|
||||||
|
* accumulates intermediate events into task.history, so the A2UI response content
|
||||||
|
* from "working" events lives there even if the final status message is empty.
|
||||||
|
*/
|
||||||
|
export function extractAllParts(result: A2AResponse): Part[] {
|
||||||
|
const parts: Part[] = [];
|
||||||
|
|
||||||
|
if (result.kind === 'message') {
|
||||||
|
parts.push(...(result.parts ?? []));
|
||||||
|
} else if (result.kind === 'task') {
|
||||||
|
// Parts from task history (accumulated intermediate status-update messages)
|
||||||
|
if (result.history) {
|
||||||
|
for (const msg of result.history) {
|
||||||
|
if (msg.parts) {
|
||||||
|
parts.push(...msg.parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Parts from the final status message
|
||||||
|
if (result.status?.message?.parts) {
|
||||||
|
parts.push(...result.status.message.parts);
|
||||||
|
}
|
||||||
|
// Parts from artifacts
|
||||||
|
if (result.artifacts) {
|
||||||
|
for (const artifact of result.artifacts) {
|
||||||
|
parts.push(...(artifact.parts ?? []));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts plain text content from response parts.
|
||||||
|
*/
|
||||||
|
export function extractTextFromParts(parts: Part[]): string {
|
||||||
|
return parts
|
||||||
|
.filter((p) => p.kind === 'text')
|
||||||
|
.map(
|
||||||
|
(p) =>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
(p as unknown as { text: string }).text,
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts A2UI data parts from response parts.
|
||||||
|
* A2UI parts are DataParts with metadata.mimeType === 'application/json+a2ui'.
|
||||||
|
*/
|
||||||
|
export function extractA2UIParts(parts: Part[]): unknown[][] {
|
||||||
|
const a2uiMessages: unknown[][] = [];
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (
|
||||||
|
part.kind === 'data' &&
|
||||||
|
part.metadata != null &&
|
||||||
|
part.metadata['mimeType'] === A2UI_MIME_TYPE
|
||||||
|
) {
|
||||||
|
// The data field is an array of A2UI messages
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
const data = (part as unknown as { data: unknown }).data;
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
a2uiMessages.push(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return a2uiMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2A client for the chat bridge.
|
||||||
|
* Manages connection to the A2A server and provides message send/receive.
|
||||||
|
*/
|
||||||
|
export class A2ABridgeClient {
|
||||||
|
private client: Client | null = null;
|
||||||
|
private agentUrl: string;
|
||||||
|
|
||||||
|
constructor(agentUrl: string) {
|
||||||
|
this.agentUrl = agentUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the client connection to the A2A server.
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (this.client) return;
|
||||||
|
|
||||||
|
const resolver = new DefaultAgentCardResolver({});
|
||||||
|
const options = ClientFactoryOptions.createFrom(
|
||||||
|
ClientFactoryOptions.default,
|
||||||
|
{
|
||||||
|
transports: [
|
||||||
|
new RestTransportFactory({}),
|
||||||
|
new JsonRpcTransportFactory({}),
|
||||||
|
],
|
||||||
|
cardResolver: resolver,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const factory = new ClientFactory(options);
|
||||||
|
// createFromUrl expects the agent card URL, not just the base URL
|
||||||
|
const agentCardUrl =
|
||||||
|
this.agentUrl.replace(/\/$/, '') + '/.well-known/agent-card.json';
|
||||||
|
this.client = await factory.createFromUrl(agentCardUrl, '');
|
||||||
|
|
||||||
|
const card = await this.client.getAgentCard();
|
||||||
|
logger.info(
|
||||||
|
`[ChatBridge] Connected to A2A agent: ${card.name} (${card.url})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a text message to the A2A server using blocking mode.
|
||||||
|
* The blocking DefaultRequestHandler accumulates all intermediate events
|
||||||
|
* (including A2UI content from "working" status updates) into the Task's
|
||||||
|
* history array, so extractAllParts can find them.
|
||||||
|
*/
|
||||||
|
async sendMessage(
|
||||||
|
text: string,
|
||||||
|
options: { contextId?: string; taskId?: string },
|
||||||
|
): Promise<A2AResponse> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('A2A client not initialized. Call initialize() first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: MessageSendParams = {
|
||||||
|
message: {
|
||||||
|
kind: 'message',
|
||||||
|
role: 'user',
|
||||||
|
messageId: uuidv4(),
|
||||||
|
parts: [{ kind: 'text', text }],
|
||||||
|
contextId: options.contextId,
|
||||||
|
taskId: options.taskId,
|
||||||
|
// Signal A2UI support in message metadata
|
||||||
|
metadata: {
|
||||||
|
extensions: [A2UI_EXTENSION_URI],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
configuration: {
|
||||||
|
blocking: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.client.sendMessage(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a tool confirmation action back to the A2A server.
|
||||||
|
* The action is sent as a DataPart containing the A2UI action message.
|
||||||
|
*/
|
||||||
|
async sendToolConfirmation(
|
||||||
|
callId: string,
|
||||||
|
outcome: string,
|
||||||
|
taskId: string,
|
||||||
|
options: { contextId?: string },
|
||||||
|
): Promise<A2AResponse> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('A2A client not initialized. Call initialize() first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the A2UI action message as a DataPart
|
||||||
|
const actionPart: Part = {
|
||||||
|
kind: 'data',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
version: 'v0.10',
|
||||||
|
action: {
|
||||||
|
name: 'tool_confirmation',
|
||||||
|
surfaceId: `tool_approval_${taskId}_${callId}`,
|
||||||
|
sourceComponentId:
|
||||||
|
outcome === 'cancel' ? 'reject_button' : 'approve_button',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
context: { callId, outcome, taskId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as unknown as Record<string, unknown>,
|
||||||
|
metadata: {
|
||||||
|
mimeType: A2UI_MIME_TYPE,
|
||||||
|
},
|
||||||
|
} as Part;
|
||||||
|
|
||||||
|
const params: MessageSendParams = {
|
||||||
|
message: {
|
||||||
|
kind: 'message',
|
||||||
|
role: 'user',
|
||||||
|
messageId: uuidv4(),
|
||||||
|
parts: [actionPart],
|
||||||
|
contextId: options.contextId,
|
||||||
|
taskId,
|
||||||
|
metadata: {
|
||||||
|
extensions: [A2UI_EXTENSION_URI],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
configuration: {
|
||||||
|
blocking: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.client.sendMessage(params);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Chat webhook handler.
|
||||||
|
* Processes incoming Google Chat events, forwards them to the A2A server,
|
||||||
|
* and converts responses back to Google Chat format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChatEvent, ChatResponse, ChatBridgeConfig } from './types.js';
|
||||||
|
import { SessionStore } from './session-store.js';
|
||||||
|
import {
|
||||||
|
A2ABridgeClient,
|
||||||
|
extractIdsFromResponse,
|
||||||
|
} from './a2a-bridge-client.js';
|
||||||
|
import { renderResponse, extractToolApprovals } from './response-renderer.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
export class ChatBridgeHandler {
|
||||||
|
private sessionStore: SessionStore;
|
||||||
|
private a2aClient: A2ABridgeClient;
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
|
constructor(private config: ChatBridgeConfig) {
|
||||||
|
this.sessionStore = new SessionStore(config.gcsBucket);
|
||||||
|
this.a2aClient = new A2ABridgeClient(config.a2aServerUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the A2A client connection and restores persisted sessions.
|
||||||
|
* Must be called before handling events.
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (this.initialized) return;
|
||||||
|
await this.a2aClient.initialize();
|
||||||
|
await this.sessionStore.restore();
|
||||||
|
this.initialized = true;
|
||||||
|
logger.info(
|
||||||
|
`[ChatBridge] Handler initialized, connected to ${this.config.a2aServerUrl}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main entry point for handling Google Chat webhook events.
|
||||||
|
*/
|
||||||
|
async handleEvent(event: ChatEvent): Promise<ChatResponse> {
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[ChatBridge] Received event: type=${event.type}, space=${event.space.name}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'MESSAGE':
|
||||||
|
return this.handleMessage(event);
|
||||||
|
case 'CARD_CLICKED':
|
||||||
|
return this.handleCardClicked(event);
|
||||||
|
case 'ADDED_TO_SPACE':
|
||||||
|
return this.handleAddedToSpace(event);
|
||||||
|
case 'REMOVED_FROM_SPACE':
|
||||||
|
return this.handleRemovedFromSpace(event);
|
||||||
|
default:
|
||||||
|
logger.warn(`[ChatBridge] Unknown event type: ${event.type}`);
|
||||||
|
return { text: 'Unknown event type.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a MESSAGE event: user sent a text message in Chat.
|
||||||
|
*/
|
||||||
|
private async handleMessage(event: ChatEvent): Promise<ChatResponse> {
|
||||||
|
const message = event.message;
|
||||||
|
if (!message?.thread?.name) {
|
||||||
|
return { text: 'Error: Missing thread information.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = message.argumentText || message.text || '';
|
||||||
|
if (!text.trim()) {
|
||||||
|
return { text: "I didn't receive any text. Please try again." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadName = message.thread.name;
|
||||||
|
const spaceName = event.space.name;
|
||||||
|
|
||||||
|
// Handle slash commands
|
||||||
|
const trimmed = text.trim().toLowerCase();
|
||||||
|
if (
|
||||||
|
trimmed === '/reset' ||
|
||||||
|
trimmed === '/clear' ||
|
||||||
|
trimmed === 'reset' ||
|
||||||
|
trimmed === 'clear'
|
||||||
|
) {
|
||||||
|
this.sessionStore.remove(threadName);
|
||||||
|
logger.info(`[ChatBridge] Session cleared for thread ${threadName}`);
|
||||||
|
return { text: 'Session cleared. Send a new message to start fresh.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.sessionStore.getOrCreate(threadName, spaceName);
|
||||||
|
|
||||||
|
if (trimmed === '/yolo') {
|
||||||
|
session.yoloMode = true;
|
||||||
|
logger.info(`[ChatBridge] YOLO mode enabled for thread ${threadName}`);
|
||||||
|
return {
|
||||||
|
text: 'YOLO mode enabled. All tool calls will be auto-approved.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === '/safe') {
|
||||||
|
session.yoloMode = false;
|
||||||
|
logger.info(`[ChatBridge] YOLO mode disabled for thread ${threadName}`);
|
||||||
|
return { text: 'Safe mode enabled. Tool calls will require approval.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[ChatBridge] MESSAGE from ${event.user.displayName}: "${text.substring(0, 100)}"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle text-based tool approval responses
|
||||||
|
const lowerText = trimmed;
|
||||||
|
if (
|
||||||
|
session.pendingToolApproval &&
|
||||||
|
(lowerText === 'approve' ||
|
||||||
|
lowerText === 'yes' ||
|
||||||
|
lowerText === 'y' ||
|
||||||
|
lowerText === 'reject' ||
|
||||||
|
lowerText === 'no' ||
|
||||||
|
lowerText === 'n' ||
|
||||||
|
lowerText === 'always allow')
|
||||||
|
) {
|
||||||
|
const approval = session.pendingToolApproval;
|
||||||
|
const isReject =
|
||||||
|
lowerText === 'reject' || lowerText === 'no' || lowerText === 'n';
|
||||||
|
const isAlwaysAllow = lowerText === 'always allow';
|
||||||
|
const outcome = isReject
|
||||||
|
? 'cancel'
|
||||||
|
: isAlwaysAllow
|
||||||
|
? 'proceed_always_tool'
|
||||||
|
: 'proceed_once';
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[ChatBridge] Text-based tool ${outcome}: callId=${approval.callId}, taskId=${approval.taskId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
session.pendingToolApproval = undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.a2aClient.sendToolConfirmation(
|
||||||
|
approval.callId,
|
||||||
|
outcome,
|
||||||
|
approval.taskId,
|
||||||
|
{ contextId: session.contextId },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { contextId: newCtxId, taskId: newTaskId } =
|
||||||
|
extractIdsFromResponse(response);
|
||||||
|
if (newCtxId) session.contextId = newCtxId;
|
||||||
|
this.sessionStore.updateTaskId(threadName, newTaskId);
|
||||||
|
|
||||||
|
const threadKey = message.thread.threadKey || threadName;
|
||||||
|
return renderResponse(response, threadKey, threadName);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg =
|
||||||
|
error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error(
|
||||||
|
`[ChatBridge] Error sending tool confirmation: ${errorMsg}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return { text: `Error processing tool confirmation: ${errorMsg}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.a2aClient.sendMessage(text, {
|
||||||
|
contextId: session.contextId,
|
||||||
|
taskId: session.taskId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update session with new IDs from response
|
||||||
|
const { contextId, taskId } = extractIdsFromResponse(response);
|
||||||
|
if (contextId) {
|
||||||
|
session.contextId = contextId;
|
||||||
|
}
|
||||||
|
this.sessionStore.updateTaskId(threadName, taskId);
|
||||||
|
|
||||||
|
// Check for pending tool approvals and store for text-based confirmation
|
||||||
|
const approvals = extractToolApprovals(response);
|
||||||
|
if (approvals.length > 0) {
|
||||||
|
const firstApproval = approvals[0];
|
||||||
|
session.pendingToolApproval = {
|
||||||
|
callId: firstApproval.callId,
|
||||||
|
taskId: firstApproval.taskId,
|
||||||
|
toolName: firstApproval.displayName || firstApproval.name,
|
||||||
|
};
|
||||||
|
logger.info(
|
||||||
|
`[ChatBridge] Pending tool approval: ${firstApproval.displayName || firstApproval.name} callId=${firstApproval.callId}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
session.pendingToolApproval = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert A2A response to Chat format
|
||||||
|
const threadKey = message.thread.threadKey || threadName;
|
||||||
|
return renderResponse(response, threadKey, threadName);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error(`[ChatBridge] Error handling message: ${errorMsg}`, error);
|
||||||
|
return {
|
||||||
|
text: `Sorry, I encountered an error processing your request: ${errorMsg}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a CARD_CLICKED event: user clicked a button on a card.
|
||||||
|
* Used for tool approval/rejection flows.
|
||||||
|
*/
|
||||||
|
private async handleCardClicked(event: ChatEvent): Promise<ChatResponse> {
|
||||||
|
const action = event.action;
|
||||||
|
if (!action) {
|
||||||
|
return { text: 'Error: Missing action data.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadName = event.message?.thread?.name;
|
||||||
|
if (!threadName) {
|
||||||
|
return { text: 'Error: Missing thread information.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.sessionStore.get(threadName);
|
||||||
|
if (!session) {
|
||||||
|
return { text: 'Error: No active session found for this thread.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[ChatBridge] CARD_CLICKED: function=${action.actionMethodName}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (action.actionMethodName === 'tool_confirmation') {
|
||||||
|
return this.handleToolConfirmation(event, session.contextId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: `Unknown action: ${action.actionMethodName}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles tool confirmation actions from card button clicks.
|
||||||
|
*/
|
||||||
|
private async handleToolConfirmation(
|
||||||
|
event: ChatEvent,
|
||||||
|
contextId: string,
|
||||||
|
): Promise<ChatResponse> {
|
||||||
|
const params = event.action?.parameters || [];
|
||||||
|
const paramMap = new Map(params.map((p) => [p.key, p.value]));
|
||||||
|
|
||||||
|
const callId = paramMap.get('callId');
|
||||||
|
const outcome = paramMap.get('outcome');
|
||||||
|
const taskId = paramMap.get('taskId');
|
||||||
|
|
||||||
|
if (!callId || !outcome || !taskId) {
|
||||||
|
return { text: 'Error: Missing tool confirmation parameters.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[ChatBridge] Tool confirmation: callId=${callId}, outcome=${outcome}, taskId=${taskId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.a2aClient.sendToolConfirmation(
|
||||||
|
callId,
|
||||||
|
outcome,
|
||||||
|
taskId,
|
||||||
|
{ contextId },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update session
|
||||||
|
const threadName = event.message?.thread?.name;
|
||||||
|
if (threadName) {
|
||||||
|
const { contextId: newContextId, taskId: newTaskId } =
|
||||||
|
extractIdsFromResponse(response);
|
||||||
|
if (newContextId) {
|
||||||
|
const session = this.sessionStore.get(threadName);
|
||||||
|
if (session) session.contextId = newContextId;
|
||||||
|
}
|
||||||
|
this.sessionStore.updateTaskId(threadName, newTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderResponse(response);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error(
|
||||||
|
`[ChatBridge] Error sending tool confirmation: ${errorMsg}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
text: `Error processing tool confirmation: ${errorMsg}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles ADDED_TO_SPACE event: bot was added to a space or DM.
|
||||||
|
*/
|
||||||
|
private handleAddedToSpace(event: ChatEvent): ChatResponse {
|
||||||
|
const spaceType = event.space.type === 'DM' ? 'DM' : 'space';
|
||||||
|
logger.info(`[ChatBridge] Bot added to ${spaceType}: ${event.space.name}`);
|
||||||
|
return {
|
||||||
|
text:
|
||||||
|
`Hello! I'm the Gemini CLI Agent. Send me a message to get started with code generation and development tasks.\n\n` +
|
||||||
|
`I can:\n` +
|
||||||
|
`- Generate code from natural language\n` +
|
||||||
|
`- Edit files and run commands\n` +
|
||||||
|
`- Answer questions about code\n\n` +
|
||||||
|
`I'll ask for your approval before executing tools.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles REMOVED_FROM_SPACE event: bot was removed from a space.
|
||||||
|
*/
|
||||||
|
private handleRemovedFromSpace(event: ChatEvent): ChatResponse {
|
||||||
|
logger.info(`[ChatBridge] Bot removed from space: ${event.space.name}`);
|
||||||
|
// Clean up any sessions for this space
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts A2A/A2UI responses into Google Chat messages and Cards V2.
|
||||||
|
*
|
||||||
|
* This renderer understands the A2UI v0.10 surface structures produced by our
|
||||||
|
* a2a-server (tool approval surfaces, agent response surfaces, thought surfaces)
|
||||||
|
* and converts them to Google Chat's Cards V2 format.
|
||||||
|
*
|
||||||
|
* Inspired by the A2UI web_core message processor pattern but simplified for
|
||||||
|
* server-side rendering to a constrained card format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ChatResponse,
|
||||||
|
ChatCardV2,
|
||||||
|
ChatCardSection,
|
||||||
|
ChatWidget,
|
||||||
|
} from './types.js';
|
||||||
|
import {
|
||||||
|
type A2AResponse,
|
||||||
|
extractAllParts,
|
||||||
|
extractTextFromParts,
|
||||||
|
extractA2UIParts,
|
||||||
|
} from './a2a-bridge-client.js';
|
||||||
|
|
||||||
|
export interface ToolApprovalInfo {
|
||||||
|
taskId: string;
|
||||||
|
callId: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
description: string;
|
||||||
|
args: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentResponseInfo {
|
||||||
|
text: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts tool approval info from an A2A response.
|
||||||
|
* Used by the handler to track pending approvals for text-based confirmation.
|
||||||
|
*/
|
||||||
|
export function extractToolApprovals(
|
||||||
|
response: A2AResponse,
|
||||||
|
): ToolApprovalInfo[] {
|
||||||
|
const parts = extractAllParts(response);
|
||||||
|
const a2uiMessageGroups = extractA2UIParts(parts);
|
||||||
|
const toolApprovals: ToolApprovalInfo[] = [];
|
||||||
|
const agentResponses: AgentResponseInfo[] = [];
|
||||||
|
const thoughts: Array<{ subject: string; description: string }> = [];
|
||||||
|
|
||||||
|
for (const messages of a2uiMessageGroups) {
|
||||||
|
parseA2UIMessages(messages, toolApprovals, agentResponses, thoughts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deduplicateToolApprovals(toolApprovals);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an A2A response as a Google Chat response.
|
||||||
|
* Extracts text content and A2UI surfaces, converting them to Chat format.
|
||||||
|
*/
|
||||||
|
export function renderResponse(
|
||||||
|
response: A2AResponse,
|
||||||
|
threadKey?: string,
|
||||||
|
threadName?: string,
|
||||||
|
): ChatResponse {
|
||||||
|
const parts = extractAllParts(response);
|
||||||
|
const textContent = extractTextFromParts(parts);
|
||||||
|
const a2uiMessageGroups = extractA2UIParts(parts);
|
||||||
|
|
||||||
|
// Parse A2UI surfaces for known types
|
||||||
|
const toolApprovals: ToolApprovalInfo[] = [];
|
||||||
|
const agentResponses: AgentResponseInfo[] = [];
|
||||||
|
const thoughts: Array<{ subject: string; description: string }> = [];
|
||||||
|
|
||||||
|
for (const messages of a2uiMessageGroups) {
|
||||||
|
parseA2UIMessages(messages, toolApprovals, agentResponses, thoughts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate tool approvals by surfaceId — A2UI history contains both
|
||||||
|
// initial 'awaiting_approval' and later 'success' events for auto-approved tools.
|
||||||
|
const dedupedApprovals = deduplicateToolApprovals(toolApprovals);
|
||||||
|
|
||||||
|
const cards: ChatCardV2[] = [];
|
||||||
|
|
||||||
|
// Only render tool approval cards for tools still awaiting approval.
|
||||||
|
// In YOLO mode, tools are auto-approved and their status becomes "success"
|
||||||
|
// so we skip rendering approval cards for those.
|
||||||
|
for (const approval of dedupedApprovals) {
|
||||||
|
if (approval.status === 'awaiting_approval') {
|
||||||
|
cards.push(renderToolApprovalCard(approval));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build text response from agent responses and plain text
|
||||||
|
const responseTexts: string[] = [];
|
||||||
|
|
||||||
|
// Add thought summaries
|
||||||
|
for (const thought of thoughts) {
|
||||||
|
responseTexts.push(`_${thought.subject}_: ${thought.description}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add agent response text (from A2UI surfaces).
|
||||||
|
// Use only the last non-empty response since later updates supersede earlier
|
||||||
|
// ones for the same surface (history contains multiple status-update messages).
|
||||||
|
for (let i = agentResponses.length - 1; i >= 0; i--) {
|
||||||
|
if (agentResponses[i].text) {
|
||||||
|
responseTexts.push(agentResponses[i].text);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to plain text content if no A2UI response text
|
||||||
|
if (responseTexts.length === 0 && textContent) {
|
||||||
|
responseTexts.push(textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add task state info
|
||||||
|
if (response.kind === 'task' && response.status) {
|
||||||
|
const state = response.status.state;
|
||||||
|
if (state === 'input-required' && cards.length > 0) {
|
||||||
|
responseTexts.push('*Waiting for your approval to continue...*');
|
||||||
|
} else if (state === 'failed') {
|
||||||
|
responseTexts.push('*Task failed.*');
|
||||||
|
} else if (state === 'canceled') {
|
||||||
|
responseTexts.push('*Task was cancelled.*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatResponse: ChatResponse = {};
|
||||||
|
|
||||||
|
if (responseTexts.length > 0) {
|
||||||
|
chatResponse.text = responseTexts.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cards.length > 0) {
|
||||||
|
chatResponse.cardsV2 = cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (threadKey || threadName) {
|
||||||
|
chatResponse.thread = {};
|
||||||
|
if (threadKey) chatResponse.thread.threadKey = threadKey;
|
||||||
|
if (threadName) chatResponse.thread.name = threadName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we always return something
|
||||||
|
if (!chatResponse.text && !chatResponse.cardsV2) {
|
||||||
|
chatResponse.text = '_Agent is processing..._';
|
||||||
|
}
|
||||||
|
|
||||||
|
return chatResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a CARD_CLICKED acknowledgment response.
|
||||||
|
*/
|
||||||
|
export function renderActionAcknowledgment(
|
||||||
|
action: string,
|
||||||
|
outcome: string,
|
||||||
|
): ChatResponse {
|
||||||
|
const emoji =
|
||||||
|
outcome === 'cancel'
|
||||||
|
? 'Rejected'
|
||||||
|
: outcome === 'proceed_always_tool'
|
||||||
|
? 'Always Allowed'
|
||||||
|
: 'Approved';
|
||||||
|
return {
|
||||||
|
actionResponse: { type: 'UPDATE_MESSAGE' },
|
||||||
|
text: `*Tool ${emoji}* - Processing...`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Safely extracts a string property from an unknown object. */
|
||||||
|
function str(obj: Record<string, unknown>, key: string): string {
|
||||||
|
const v = obj[key];
|
||||||
|
return typeof v === 'string' ? v : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Safely checks if an unknown value is a record. */
|
||||||
|
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||||
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Safely extracts a nested object property. */
|
||||||
|
function obj(
|
||||||
|
parent: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
const v = parent[key];
|
||||||
|
return isRecord(v) ? v : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates tool approvals by surfaceId, keeping the last entry per surface.
|
||||||
|
* In blocking mode, A2UI history accumulates ALL intermediate events — a tool
|
||||||
|
* surface may appear first as 'awaiting_approval' then as 'success' (YOLO mode).
|
||||||
|
* By keeping only the last entry per surfaceId, auto-approved tools show 'success'.
|
||||||
|
*/
|
||||||
|
function deduplicateToolApprovals(
|
||||||
|
approvals: ToolApprovalInfo[],
|
||||||
|
): ToolApprovalInfo[] {
|
||||||
|
const byId = new Map<string, ToolApprovalInfo>();
|
||||||
|
for (const a of approvals) {
|
||||||
|
const key = `${a.taskId}_${a.callId}`;
|
||||||
|
byId.set(key, a);
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses A2UI v0.10 messages to extract known surface types.
|
||||||
|
* Our server produces specific surfaces: tool approval, agent response, thought.
|
||||||
|
*/
|
||||||
|
function parseA2UIMessages(
|
||||||
|
messages: unknown[],
|
||||||
|
toolApprovals: ToolApprovalInfo[],
|
||||||
|
agentResponses: AgentResponseInfo[],
|
||||||
|
thoughts: Array<{ subject: string; description: string }>,
|
||||||
|
): void {
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!isRecord(msg)) continue;
|
||||||
|
|
||||||
|
// Look for updateDataModel messages that contain tool approval or response data
|
||||||
|
const updateDM = obj(msg, 'updateDataModel');
|
||||||
|
if (updateDM) {
|
||||||
|
const surfaceId = str(updateDM, 'surfaceId');
|
||||||
|
const value = obj(updateDM, 'value');
|
||||||
|
const path = str(updateDM, 'path');
|
||||||
|
|
||||||
|
if (value && !path) {
|
||||||
|
// Full data model update (initial) - check for known structures
|
||||||
|
const tool = obj(value, 'tool');
|
||||||
|
if (surfaceId.startsWith('tool_approval_') && tool) {
|
||||||
|
toolApprovals.push({
|
||||||
|
taskId: str(value, 'taskId'),
|
||||||
|
callId: str(tool, 'callId'),
|
||||||
|
name: str(tool, 'name'),
|
||||||
|
displayName: str(tool, 'displayName'),
|
||||||
|
description: str(tool, 'description'),
|
||||||
|
args: str(tool, 'args'),
|
||||||
|
kind: str(tool, 'kind') || 'tool',
|
||||||
|
status: str(tool, 'status') || 'unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = obj(value, 'response');
|
||||||
|
if (surfaceId.startsWith('agent_response_') && resp) {
|
||||||
|
agentResponses.push({
|
||||||
|
text: str(resp, 'text'),
|
||||||
|
status: str(resp, 'status'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial data model updates (path-based)
|
||||||
|
if (path === '/response/text' && updateDM['value'] != null) {
|
||||||
|
agentResponses.push({
|
||||||
|
text: String(updateDM['value']),
|
||||||
|
status: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool status updates (e.g., YOLO mode changes status to 'success')
|
||||||
|
if (
|
||||||
|
surfaceId.startsWith('tool_approval_') &&
|
||||||
|
path === '/tool/status' &&
|
||||||
|
typeof updateDM['value'] === 'string'
|
||||||
|
) {
|
||||||
|
// Find existing tool approval for this surface and update its status
|
||||||
|
const existing = toolApprovals.find(
|
||||||
|
(a) => `tool_approval_${a.taskId}_${a.callId}` === surfaceId,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
existing.status = updateDM['value'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for updateComponents to extract thought text
|
||||||
|
const updateComp = obj(msg, 'updateComponents');
|
||||||
|
if (updateComp) {
|
||||||
|
const surfaceId = str(updateComp, 'surfaceId');
|
||||||
|
const components = updateComp['components'];
|
||||||
|
|
||||||
|
if (surfaceId.startsWith('thought_') && Array.isArray(components)) {
|
||||||
|
const subject = extractComponentText(components, 'thought_subject');
|
||||||
|
const desc = extractComponentText(components, 'thought_desc');
|
||||||
|
if (subject || desc) {
|
||||||
|
thoughts.push({
|
||||||
|
subject: subject || 'Thinking',
|
||||||
|
description: desc || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the text content from a named component in a component array.
|
||||||
|
* Components use our a2ui-components.ts builder format.
|
||||||
|
*/
|
||||||
|
function extractComponentText(
|
||||||
|
components: unknown[],
|
||||||
|
componentId: string,
|
||||||
|
): string {
|
||||||
|
for (const comp of components) {
|
||||||
|
if (!isRecord(comp)) continue;
|
||||||
|
if (comp['id'] === componentId && comp['component'] === 'text') {
|
||||||
|
return str(comp, 'text');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a concise command summary from tool approval args.
|
||||||
|
* For shell tools, returns just the command string.
|
||||||
|
* For file tools, returns the file path.
|
||||||
|
*/
|
||||||
|
function extractCommandSummary(approval: ToolApprovalInfo): string {
|
||||||
|
if (!approval.args || approval.args === 'No arguments') return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(approval.args);
|
||||||
|
if (isRecord(parsed)) {
|
||||||
|
// Shell tool: {"command": "ls -F"}
|
||||||
|
if (typeof parsed['command'] === 'string') {
|
||||||
|
return parsed['command'];
|
||||||
|
}
|
||||||
|
// File tools: {"file_path": "/path/to/file", ...}
|
||||||
|
if (typeof parsed['file_path'] === 'string') {
|
||||||
|
const action =
|
||||||
|
approval.name || approval.displayName || 'File operation';
|
||||||
|
return `${action}: ${parsed['file_path']}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON, return as-is if short enough
|
||||||
|
if (approval.args.length <= 200) return approval.args;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a tool approval surface as a Google Chat Card V2.
|
||||||
|
*/
|
||||||
|
function renderToolApprovalCard(approval: ToolApprovalInfo): ChatCardV2 {
|
||||||
|
const widgets: ChatWidget[] = [];
|
||||||
|
|
||||||
|
// Show a concise summary of what the tool will do.
|
||||||
|
// For shell commands, extract just the command string from the args JSON.
|
||||||
|
const commandSummary = extractCommandSummary(approval);
|
||||||
|
if (commandSummary) {
|
||||||
|
widgets.push({
|
||||||
|
decoratedText: {
|
||||||
|
text: `\`${commandSummary}\``,
|
||||||
|
topLabel: approval.displayName || approval.name,
|
||||||
|
startIcon: { knownIcon: 'DESCRIPTION' },
|
||||||
|
wrapText: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (approval.args && approval.args !== 'No arguments') {
|
||||||
|
// Fallback: show truncated args
|
||||||
|
const truncatedArgs =
|
||||||
|
approval.args.length > 300
|
||||||
|
? approval.args.substring(0, 300) + '...'
|
||||||
|
: approval.args;
|
||||||
|
widgets.push({
|
||||||
|
decoratedText: {
|
||||||
|
text: truncatedArgs,
|
||||||
|
topLabel: approval.displayName || approval.name,
|
||||||
|
startIcon: { knownIcon: 'DESCRIPTION' },
|
||||||
|
wrapText: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text-based approval instructions (card click buttons don't work
|
||||||
|
// with the current Add-ons routing configuration)
|
||||||
|
widgets.push({
|
||||||
|
textParagraph: {
|
||||||
|
text: 'Reply <b>approve</b>, <b>always allow</b>, or <b>reject</b>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sections: ChatCardSection[] = [
|
||||||
|
{
|
||||||
|
widgets,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
cardId: `tool_approval_${approval.callId}`,
|
||||||
|
card: {
|
||||||
|
header: {
|
||||||
|
title: 'Tool Approval Required',
|
||||||
|
subtitle: approval.displayName || approval.name,
|
||||||
|
},
|
||||||
|
sections,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express routes for the Google Chat bridge webhook.
|
||||||
|
* Adds a POST /chat/webhook endpoint to the existing Express app.
|
||||||
|
* Includes JWT verification for Google Chat requests when configured.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { Router as createRouter } from 'express';
|
||||||
|
import { OAuth2Client } from 'google-auth-library';
|
||||||
|
import type { ChatEvent, ChatBridgeConfig, ChatResponse } from './types.js';
|
||||||
|
import { ChatBridgeHandler } from './handler.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
const CHAT_ISSUER = 'chat@system.gserviceaccount.com';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates middleware that verifies Google Chat JWT tokens.
|
||||||
|
*
|
||||||
|
* On Cloud Run (detected via K_SERVICE env var), authentication is handled by
|
||||||
|
* Cloud Run's IAM layer — only principals with roles/run.invoker can reach the
|
||||||
|
* container. Cloud Run strips the Authorization header after validation, so our
|
||||||
|
* middleware cannot re-verify the token. We trust Cloud Run's IAM instead.
|
||||||
|
*
|
||||||
|
* When NOT on Cloud Run and projectNumber is set, requests must include a valid
|
||||||
|
* Bearer token signed by Google Chat with the correct audience.
|
||||||
|
*
|
||||||
|
* When neither condition applies, verification is skipped (local testing).
|
||||||
|
*/
|
||||||
|
function createAuthMiddleware(
|
||||||
|
projectNumber: string | undefined,
|
||||||
|
): (req: Request, res: Response, next: NextFunction) => void {
|
||||||
|
// On Cloud Run, IAM handles auth — the Authorization header is stripped
|
||||||
|
// before reaching the container, so we cannot verify it ourselves.
|
||||||
|
if (process.env['K_SERVICE']) {
|
||||||
|
logger.info(
|
||||||
|
'[ChatBridge] Running on Cloud Run — auth delegated to Cloud Run IAM.',
|
||||||
|
);
|
||||||
|
return (_req: Request, _res: Response, next: NextFunction) => {
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!projectNumber) {
|
||||||
|
logger.warn(
|
||||||
|
'[ChatBridge] CHAT_PROJECT_NUMBER not set — JWT verification disabled. ' +
|
||||||
|
'Set it in production to verify requests come from Google Chat.',
|
||||||
|
);
|
||||||
|
return (_req: Request, _res: Response, next: NextFunction) => {
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const authClient = new OAuth2Client();
|
||||||
|
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
logger.warn('[ChatBridge] Missing or invalid Authorization header');
|
||||||
|
res.status(401).json({ error: 'Unauthorized: missing Bearer token' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
|
||||||
|
// Debug: decode token payload without verification to inspect claims
|
||||||
|
try {
|
||||||
|
const payloadB64 = token.split('.')[1];
|
||||||
|
if (payloadB64) {
|
||||||
|
const decoded = JSON.parse(
|
||||||
|
Buffer.from(payloadB64, 'base64').toString(),
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`[ChatBridge] Token claims: iss=${String(decoded.iss ?? 'none')} ` +
|
||||||
|
`aud=${String(decoded.aud ?? 'none')} ` +
|
||||||
|
`email=${String(decoded.email ?? 'none')} ` +
|
||||||
|
`sub=${String(decoded.sub ?? 'none')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.warn('[ChatBridge] Could not decode token for debug logging');
|
||||||
|
}
|
||||||
|
|
||||||
|
authClient
|
||||||
|
.verifyIdToken({
|
||||||
|
idToken: token,
|
||||||
|
audience: projectNumber,
|
||||||
|
})
|
||||||
|
.then((ticket) => {
|
||||||
|
const payload = ticket.getPayload();
|
||||||
|
if (payload?.iss !== CHAT_ISSUER) {
|
||||||
|
logger.warn(
|
||||||
|
`[ChatBridge] Invalid token issuer: ${payload?.iss ?? 'unknown'}`,
|
||||||
|
);
|
||||||
|
res.status(403).json({ error: 'Forbidden: invalid token issuer' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
logger.warn(`[ChatBridge] Token verification failed: ${msg}`);
|
||||||
|
res.status(401).json({ error: 'Unauthorized: invalid token' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Safely extract a string from an unknown record. */
|
||||||
|
function str(obj: Record<string, unknown>, key: string): string {
|
||||||
|
const v = obj[key];
|
||||||
|
return typeof v === 'string' ? v : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Safely check if a value is a plain object. */
|
||||||
|
function isObj(v: unknown): v is Record<string, unknown> {
|
||||||
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a Google Chat event to the legacy ChatEvent format.
|
||||||
|
* Workspace Add-ons send: {chat: {messagePayload, user, ...}, commonEventObject}
|
||||||
|
* Legacy format: {type: "MESSAGE", message: {...}, space: {...}, user: {...}}
|
||||||
|
*/
|
||||||
|
function normalizeEvent(raw: Record<string, unknown>): ChatEvent | null {
|
||||||
|
// Already in legacy format
|
||||||
|
if (typeof raw['type'] === 'string') {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
return raw as unknown as ChatEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workspace Add-ons format
|
||||||
|
const chat = raw['chat'];
|
||||||
|
if (!isObj(chat)) return null;
|
||||||
|
|
||||||
|
const user = isObj(chat['user']) ? chat['user'] : {};
|
||||||
|
const eventTime = str(chat, 'eventTime');
|
||||||
|
|
||||||
|
// Check for card click actions (button clicks) via commonEventObject
|
||||||
|
const common = raw['commonEventObject'];
|
||||||
|
if (isObj(common) && typeof common['invokedFunction'] === 'string') {
|
||||||
|
const invokedFunction = common['invokedFunction'];
|
||||||
|
const params = isObj(common['parameters']) ? common['parameters'] : {};
|
||||||
|
|
||||||
|
// Build action parameters array from commonEventObject.parameters
|
||||||
|
const actionParams = Object.entries(params)
|
||||||
|
.filter(([, v]) => typeof v === 'string')
|
||||||
|
.map(([key, value]) => ({ key, value: String(value) }));
|
||||||
|
|
||||||
|
// Extract message/thread/space from chat object
|
||||||
|
const message = isObj(chat['message']) ? chat['message'] : {};
|
||||||
|
const thread = isObj(message['thread']) ? message['thread'] : {};
|
||||||
|
const space = isObj(chat['space'])
|
||||||
|
? chat['space']
|
||||||
|
: isObj(message['space'])
|
||||||
|
? message['space']
|
||||||
|
: {};
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[ChatBridge] Add-ons CARD_CLICKED: function=${invokedFunction} ` +
|
||||||
|
`params=${JSON.stringify(params)} thread=${str(thread, 'name')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
return {
|
||||||
|
type: 'CARD_CLICKED',
|
||||||
|
eventTime,
|
||||||
|
message: { ...message, thread, space },
|
||||||
|
space,
|
||||||
|
user,
|
||||||
|
action: {
|
||||||
|
actionMethodName: invokedFunction,
|
||||||
|
parameters: actionParams,
|
||||||
|
},
|
||||||
|
} as unknown as ChatEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine event type from which payload field is present
|
||||||
|
if (isObj(chat['messagePayload'])) {
|
||||||
|
const payload = chat['messagePayload'];
|
||||||
|
const message = isObj(payload['message']) ? payload['message'] : {};
|
||||||
|
const space = isObj(payload['space'])
|
||||||
|
? payload['space']
|
||||||
|
: isObj(message['space'])
|
||||||
|
? message['space']
|
||||||
|
: {};
|
||||||
|
const thread = isObj(message['thread']) ? message['thread'] : {};
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[ChatBridge] Add-ons MESSAGE: text="${str(message, 'text')}" ` +
|
||||||
|
`space=${str(space, 'name')} thread=${str(thread, 'name')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
return {
|
||||||
|
type: 'MESSAGE',
|
||||||
|
eventTime,
|
||||||
|
message: {
|
||||||
|
...message,
|
||||||
|
sender: message['sender'] ?? user,
|
||||||
|
thread,
|
||||||
|
space,
|
||||||
|
},
|
||||||
|
space,
|
||||||
|
user,
|
||||||
|
} as unknown as ChatEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isObj(chat['addedToSpacePayload'])) {
|
||||||
|
const payload = chat['addedToSpacePayload'];
|
||||||
|
const space = isObj(payload['space']) ? payload['space'] : {};
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
return {
|
||||||
|
type: 'ADDED_TO_SPACE',
|
||||||
|
eventTime,
|
||||||
|
space,
|
||||||
|
user,
|
||||||
|
} as unknown as ChatEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isObj(chat['removedFromSpacePayload'])) {
|
||||||
|
const payload = chat['removedFromSpacePayload'];
|
||||||
|
const space = isObj(payload['space']) ? payload['space'] : {};
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
return {
|
||||||
|
type: 'REMOVED_FROM_SPACE',
|
||||||
|
eventTime,
|
||||||
|
space,
|
||||||
|
user,
|
||||||
|
} as unknown as ChatEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`[ChatBridge] Unknown Add-ons event, chat keys: ${Object.keys(chat).join(',')}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a legacy ChatResponse in the Workspace Add-ons response format.
|
||||||
|
* Add-ons expects: {hostAppDataAction: {chatDataAction: {createMessageAction: {message}}}}
|
||||||
|
*/
|
||||||
|
function wrapAddOnsResponse(response: ChatResponse): Record<string, unknown> {
|
||||||
|
// Build the message object for the Add-ons format
|
||||||
|
const message: Record<string, unknown> = {};
|
||||||
|
if (response.text) {
|
||||||
|
message['text'] = response.text;
|
||||||
|
}
|
||||||
|
if (response.cardsV2) {
|
||||||
|
message['cardsV2'] = response.cardsV2;
|
||||||
|
}
|
||||||
|
// Include thread info so the reply goes to the user's thread
|
||||||
|
// instead of appearing as a top-level message
|
||||||
|
if (response.thread) {
|
||||||
|
const thread: Record<string, string> = {};
|
||||||
|
if (response.thread.name) thread['name'] = response.thread.name;
|
||||||
|
if (response.thread.threadKey)
|
||||||
|
thread['threadKey'] = response.thread.threadKey;
|
||||||
|
message['thread'] = thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For action responses (like CARD_CLICKED acknowledgments), use updateMessageAction
|
||||||
|
if (response.actionResponse?.type === 'UPDATE_MESSAGE') {
|
||||||
|
return {
|
||||||
|
hostAppDataAction: {
|
||||||
|
chatDataAction: {
|
||||||
|
updateMessageAction: { message },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hostAppDataAction: {
|
||||||
|
chatDataAction: {
|
||||||
|
createMessageAction: { message },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates Express routes for the Google Chat bridge.
|
||||||
|
*/
|
||||||
|
export function createChatBridgeRoutes(config: ChatBridgeConfig): Router {
|
||||||
|
const router = createRouter();
|
||||||
|
const handler = new ChatBridgeHandler(config);
|
||||||
|
const authMiddleware = createAuthMiddleware(config.projectNumber);
|
||||||
|
|
||||||
|
// Google Chat sends webhook events as POST requests
|
||||||
|
router.post(
|
||||||
|
'/chat/webhook',
|
||||||
|
authMiddleware,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
const rawBody = req.body as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Normalize to legacy ChatEvent format. Google Chat HTTP endpoints
|
||||||
|
// configured as Workspace Add-ons send a different event structure:
|
||||||
|
// {chat: {messagePayload, user, eventTime}, commonEventObject: {...}}
|
||||||
|
// We convert to the legacy format our handler expects:
|
||||||
|
// {type: "MESSAGE", message: {...}, space: {...}, user: {...}}
|
||||||
|
const event = normalizeEvent(rawBody);
|
||||||
|
|
||||||
|
if (!event || !event.type) {
|
||||||
|
logger.warn(
|
||||||
|
`[ChatBridge] Could not parse event. Keys: ${Object.keys(rawBody).join(',')}`,
|
||||||
|
);
|
||||||
|
res.status(400).json({ error: 'Invalid event: missing type field' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[ChatBridge] Webhook received: type=${event.type}`);
|
||||||
|
|
||||||
|
// Detect if the request came in Add-ons format
|
||||||
|
const isAddOnsFormat = Boolean(rawBody['chat'] && !rawBody['type']);
|
||||||
|
|
||||||
|
const response = await handler.handleEvent(event);
|
||||||
|
|
||||||
|
// For CARD_CLICKED events, force UPDATE_MESSAGE so the card is
|
||||||
|
// replaced in-place rather than posting a new message.
|
||||||
|
if (event.type === 'CARD_CLICKED' && !response.actionResponse) {
|
||||||
|
response.actionResponse = { type: 'UPDATE_MESSAGE' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAddOnsFormat) {
|
||||||
|
// Wrap in Workspace Add-ons response format
|
||||||
|
const addOnsResponse = wrapAddOnsResponse(response);
|
||||||
|
logger.info(
|
||||||
|
`[ChatBridge] Add-ons response: ${JSON.stringify(addOnsResponse).substring(0, 200)}`,
|
||||||
|
);
|
||||||
|
res.json(addOnsResponse);
|
||||||
|
} else {
|
||||||
|
res.json(response);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg =
|
||||||
|
error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error(`[ChatBridge] Webhook error: ${errorMsg}`, error);
|
||||||
|
res.status(500).json({
|
||||||
|
text: `Internal error: ${errorMsg}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Health check endpoint for the chat bridge (no auth required)
|
||||||
|
router.get('/chat/health', (_req: Request, res: Response) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
bridge: 'google-chat',
|
||||||
|
a2aServerUrl: config.a2aServerUrl,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages mapping between Google Chat threads and A2A sessions.
|
||||||
|
* Each Google Chat thread maintains a persistent contextId (conversation)
|
||||||
|
* and a transient taskId (active task within that conversation).
|
||||||
|
*
|
||||||
|
* Supports optional GCS persistence so session mappings survive
|
||||||
|
* Cloud Run instance restarts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
export interface PendingToolApproval {
|
||||||
|
callId: string;
|
||||||
|
taskId: string;
|
||||||
|
toolName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionInfo {
|
||||||
|
/** A2A contextId - persists for the lifetime of the Chat thread. */
|
||||||
|
contextId: string;
|
||||||
|
/** A2A taskId - cleared on terminal states, reused on input-required. */
|
||||||
|
taskId?: string;
|
||||||
|
/** Space name for async messaging. */
|
||||||
|
spaceName: string;
|
||||||
|
/** Thread name for async messaging. */
|
||||||
|
threadName: string;
|
||||||
|
/** Last activity timestamp. */
|
||||||
|
lastActivity: number;
|
||||||
|
/** Pending tool approval waiting for text-based response. */
|
||||||
|
pendingToolApproval?: PendingToolApproval;
|
||||||
|
/** When true, all tool calls are auto-approved. */
|
||||||
|
yoloMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serializable subset of SessionInfo for GCS persistence. */
|
||||||
|
interface PersistedSession {
|
||||||
|
contextId: string;
|
||||||
|
taskId?: string;
|
||||||
|
spaceName: string;
|
||||||
|
threadName: string;
|
||||||
|
lastActivity: number;
|
||||||
|
yoloMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session store mapping Google Chat thread names to A2A sessions.
|
||||||
|
* Optionally backed by GCS for persistence across restarts.
|
||||||
|
*/
|
||||||
|
export class SessionStore {
|
||||||
|
private sessions = new Map<string, SessionInfo>();
|
||||||
|
private gcsBucket?: string;
|
||||||
|
private gcsObjectPath = 'chat-bridge/sessions.json';
|
||||||
|
private dirty = false;
|
||||||
|
private flushTimer?: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
|
constructor(gcsBucket?: string) {
|
||||||
|
this.gcsBucket = gcsBucket;
|
||||||
|
if (gcsBucket) {
|
||||||
|
// Flush to GCS every 30 seconds if dirty
|
||||||
|
this.flushTimer = setInterval(() => {
|
||||||
|
if (this.dirty) {
|
||||||
|
this.persistToGCS().catch((err) =>
|
||||||
|
logger.warn(`[ChatBridge] GCS session flush failed:`, err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores sessions from GCS on startup.
|
||||||
|
*/
|
||||||
|
async restore(): Promise<void> {
|
||||||
|
if (!this.gcsBucket) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { Storage } = await import('@google-cloud/storage');
|
||||||
|
const storage = new Storage();
|
||||||
|
const file = storage.bucket(this.gcsBucket).file(this.gcsObjectPath);
|
||||||
|
const [exists] = await file.exists();
|
||||||
|
if (!exists) {
|
||||||
|
logger.info('[ChatBridge] No persisted sessions found in GCS.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [contents] = await file.download();
|
||||||
|
const persisted: PersistedSession[] = JSON.parse(contents.toString());
|
||||||
|
for (const s of persisted) {
|
||||||
|
this.sessions.set(s.threadName, {
|
||||||
|
contextId: s.contextId,
|
||||||
|
taskId: s.taskId,
|
||||||
|
spaceName: s.spaceName,
|
||||||
|
threadName: s.threadName,
|
||||||
|
lastActivity: s.lastActivity,
|
||||||
|
yoloMode: s.yoloMode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
`[ChatBridge] Restored ${persisted.length} sessions from GCS.`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[ChatBridge] Could not restore sessions from GCS:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists current sessions to GCS.
|
||||||
|
*/
|
||||||
|
private async persistToGCS(): Promise<void> {
|
||||||
|
if (!this.gcsBucket) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { Storage } = await import('@google-cloud/storage');
|
||||||
|
const storage = new Storage();
|
||||||
|
const file = storage.bucket(this.gcsBucket).file(this.gcsObjectPath);
|
||||||
|
|
||||||
|
const persisted: PersistedSession[] = [];
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
persisted.push({
|
||||||
|
contextId: session.contextId,
|
||||||
|
taskId: session.taskId,
|
||||||
|
spaceName: session.spaceName,
|
||||||
|
threadName: session.threadName,
|
||||||
|
lastActivity: session.lastActivity,
|
||||||
|
yoloMode: session.yoloMode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await file.save(JSON.stringify(persisted), {
|
||||||
|
contentType: 'application/json',
|
||||||
|
});
|
||||||
|
this.dirty = false;
|
||||||
|
logger.info(
|
||||||
|
`[ChatBridge] Persisted ${persisted.length} sessions to GCS.`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[ChatBridge] Failed to persist sessions to GCS:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets or creates a session for a Google Chat thread.
|
||||||
|
*/
|
||||||
|
getOrCreate(threadName: string, spaceName: string): SessionInfo {
|
||||||
|
let session = this.sessions.get(threadName);
|
||||||
|
if (!session) {
|
||||||
|
session = {
|
||||||
|
contextId: uuidv4(),
|
||||||
|
spaceName,
|
||||||
|
threadName,
|
||||||
|
lastActivity: Date.now(),
|
||||||
|
};
|
||||||
|
this.sessions.set(threadName, session);
|
||||||
|
this.dirty = true;
|
||||||
|
logger.info(
|
||||||
|
`[ChatBridge] New session for thread ${threadName}: contextId=${session.contextId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
session.lastActivity = Date.now();
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets an existing session by thread name.
|
||||||
|
*/
|
||||||
|
get(threadName: string): SessionInfo | undefined {
|
||||||
|
return this.sessions.get(threadName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the taskId for a session.
|
||||||
|
*/
|
||||||
|
updateTaskId(threadName: string, taskId: string | undefined): void {
|
||||||
|
const session = this.sessions.get(threadName);
|
||||||
|
if (session) {
|
||||||
|
session.taskId = taskId;
|
||||||
|
this.dirty = true;
|
||||||
|
logger.info(
|
||||||
|
`[ChatBridge] Session ${threadName}: taskId=${taskId ?? 'cleared'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a session (e.g. when bot is removed from space).
|
||||||
|
*/
|
||||||
|
remove(threadName: string): void {
|
||||||
|
this.sessions.delete(threadName);
|
||||||
|
this.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up stale sessions older than the given max age (ms).
|
||||||
|
*/
|
||||||
|
cleanup(maxAgeMs: number = 24 * 60 * 60 * 1000): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [threadName, session] of this.sessions.entries()) {
|
||||||
|
if (now - session.lastActivity > maxAgeMs) {
|
||||||
|
this.sessions.delete(threadName);
|
||||||
|
this.dirty = true;
|
||||||
|
logger.info(`[ChatBridge] Cleaned up stale session: ${threadName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forces an immediate flush to GCS.
|
||||||
|
*/
|
||||||
|
async flush(): Promise<void> {
|
||||||
|
if (this.dirty) {
|
||||||
|
await this.persistToGCS();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the periodic flush timer.
|
||||||
|
*/
|
||||||
|
dispose(): void {
|
||||||
|
if (this.flushTimer) {
|
||||||
|
clearInterval(this.flushTimer);
|
||||||
|
this.flushTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Chat HTTP endpoint event types.
|
||||||
|
* @see https://developers.google.com/workspace/chat/api/reference/rest/v1/Event
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ChatUser {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
type?: 'HUMAN' | 'BOT';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatThread {
|
||||||
|
name: string;
|
||||||
|
threadKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatSpace {
|
||||||
|
name: string;
|
||||||
|
type: 'DM' | 'ROOM' | 'SPACE';
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
name: string;
|
||||||
|
sender: ChatUser;
|
||||||
|
createTime: string;
|
||||||
|
text?: string;
|
||||||
|
argumentText?: string;
|
||||||
|
thread: ChatThread;
|
||||||
|
space: ChatSpace;
|
||||||
|
cardsV2?: ChatCardV2[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatActionParameter {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatAction {
|
||||||
|
actionMethodName: string;
|
||||||
|
parameters: ChatActionParameter[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChatEventType =
|
||||||
|
| 'MESSAGE'
|
||||||
|
| 'CARD_CLICKED'
|
||||||
|
| 'ADDED_TO_SPACE'
|
||||||
|
| 'REMOVED_FROM_SPACE';
|
||||||
|
|
||||||
|
export interface ChatEvent {
|
||||||
|
type: ChatEventType;
|
||||||
|
eventTime: string;
|
||||||
|
message?: ChatMessage;
|
||||||
|
space: ChatSpace;
|
||||||
|
user: ChatUser;
|
||||||
|
action?: ChatAction;
|
||||||
|
common?: Record<string, unknown>;
|
||||||
|
threadKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google Chat Cards V2 response types
|
||||||
|
|
||||||
|
export interface ChatCardV2 {
|
||||||
|
cardId: string;
|
||||||
|
card: ChatCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatCard {
|
||||||
|
header?: ChatCardHeader;
|
||||||
|
sections: ChatCardSection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatCardHeader {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
imageType?: 'CIRCLE' | 'SQUARE';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatCardSection {
|
||||||
|
header?: string;
|
||||||
|
widgets: ChatWidget[];
|
||||||
|
collapsible?: boolean;
|
||||||
|
uncollapsibleWidgetsCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChatWidget =
|
||||||
|
| { textParagraph: { text: string } }
|
||||||
|
| { decoratedText: ChatDecoratedText }
|
||||||
|
| { buttonList: { buttons: ChatButton[] } }
|
||||||
|
| { divider: Record<string, never> };
|
||||||
|
|
||||||
|
export interface ChatDecoratedText {
|
||||||
|
text: string;
|
||||||
|
topLabel?: string;
|
||||||
|
bottomLabel?: string;
|
||||||
|
startIcon?: { knownIcon: string };
|
||||||
|
wrapText?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatButton {
|
||||||
|
text: string;
|
||||||
|
onClick: ChatOnClick;
|
||||||
|
color?: { red: number; green: number; blue: number; alpha?: number };
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatOnClick {
|
||||||
|
action: {
|
||||||
|
function: string;
|
||||||
|
parameters: ChatActionParameter[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatResponse {
|
||||||
|
text?: string;
|
||||||
|
cardsV2?: ChatCardV2[];
|
||||||
|
thread?: { threadKey?: string; name?: string };
|
||||||
|
actionResponse?: {
|
||||||
|
type: 'NEW_MESSAGE' | 'UPDATE_MESSAGE' | 'REQUEST_CONFIG';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bridge configuration
|
||||||
|
|
||||||
|
export interface ChatBridgeConfig {
|
||||||
|
/** URL of the A2A server to connect to (e.g. http://localhost:8080) */
|
||||||
|
a2aServerUrl: string;
|
||||||
|
/** Google Chat project number for verification (optional) */
|
||||||
|
projectNumber?: string;
|
||||||
|
/** Whether to enable debug logging */
|
||||||
|
debug?: boolean;
|
||||||
|
/** GCS bucket name for session persistence (optional) */
|
||||||
|
gcsBucket?: string;
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ import { commandRegistry } from '../commands/command-registry.js';
|
|||||||
import { debugLogger, SimpleExtensionLoader } from '@google/gemini-cli-core';
|
import { debugLogger, SimpleExtensionLoader } from '@google/gemini-cli-core';
|
||||||
import type { Command, CommandArgument } from '../commands/types.js';
|
import type { Command, CommandArgument } from '../commands/types.js';
|
||||||
import { GitService } from '@google/gemini-cli-core';
|
import { GitService } from '@google/gemini-cli-core';
|
||||||
|
import { getA2UIAgentExtension } from '../a2ui/a2ui-extension.js';
|
||||||
|
import { createChatBridgeRoutes } from '../chat-bridge/routes.js';
|
||||||
|
|
||||||
type CommandResponse = {
|
type CommandResponse = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -46,11 +48,12 @@ const coderAgentCard: AgentCard = {
|
|||||||
url: 'https://google.com',
|
url: 'https://google.com',
|
||||||
},
|
},
|
||||||
protocolVersion: '0.3.0',
|
protocolVersion: '0.3.0',
|
||||||
version: '0.0.2', // Incremented version
|
version: '0.1.0', // A2UI-enabled version
|
||||||
capabilities: {
|
capabilities: {
|
||||||
streaming: true,
|
streaming: true,
|
||||||
pushNotifications: false,
|
pushNotifications: true,
|
||||||
stateTransitionHistory: true,
|
stateTransitionHistory: true,
|
||||||
|
extensions: [getA2UIAgentExtension()],
|
||||||
},
|
},
|
||||||
securitySchemes: undefined,
|
securitySchemes: undefined,
|
||||||
security: undefined,
|
security: undefined,
|
||||||
@@ -200,6 +203,28 @@ export async function createApp() {
|
|||||||
requestStorage.run({ req }, next);
|
requestStorage.run({ req }, next);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mount Google Chat bridge routes BEFORE A2A SDK routes.
|
||||||
|
// The A2A SDK's setupRoutes registers a catch-all jsonRpcHandler middleware
|
||||||
|
// at baseUrl="" that intercepts ALL POST requests and returns 400 for
|
||||||
|
// non-JSON-RPC payloads. Chat bridge must be registered first.
|
||||||
|
const chatBridgeUrl =
|
||||||
|
process.env['CHAT_BRIDGE_A2A_URL'] || process.env['CODER_AGENT_PORT']
|
||||||
|
? `http://localhost:${process.env['CODER_AGENT_PORT'] || '8080'}`
|
||||||
|
: undefined;
|
||||||
|
if (chatBridgeUrl) {
|
||||||
|
expressApp.use(express.json());
|
||||||
|
const chatRoutes = createChatBridgeRoutes({
|
||||||
|
a2aServerUrl: chatBridgeUrl,
|
||||||
|
projectNumber: process.env['CHAT_PROJECT_NUMBER'],
|
||||||
|
debug: process.env['CHAT_BRIDGE_DEBUG'] === 'true',
|
||||||
|
gcsBucket: process.env['GCS_BUCKET_NAME'],
|
||||||
|
});
|
||||||
|
expressApp.use(chatRoutes);
|
||||||
|
logger.info(
|
||||||
|
`[CoreAgent] Google Chat bridge enabled at /chat/webhook (A2A: ${chatBridgeUrl})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const appBuilder = new A2AExpressApp(requestHandler);
|
const appBuilder = new A2AExpressApp(requestHandler);
|
||||||
expressApp = appBuilder.setupRoutes(expressApp, '');
|
expressApp = appBuilder.setupRoutes(expressApp, '');
|
||||||
expressApp.use(express.json());
|
expressApp.use(express.json());
|
||||||
@@ -330,7 +355,8 @@ export async function main() {
|
|||||||
const expressApp = await createApp();
|
const expressApp = await createApp();
|
||||||
const port = Number(process.env['CODER_AGENT_PORT'] || 0);
|
const port = Number(process.env['CODER_AGENT_PORT'] || 0);
|
||||||
|
|
||||||
const server = expressApp.listen(port, 'localhost', () => {
|
const host = process.env['CODER_AGENT_HOST'] || 'localhost';
|
||||||
|
const server = expressApp.listen(port, host, () => {
|
||||||
const address = server.address();
|
const address = server.address();
|
||||||
let actualPort;
|
let actualPort;
|
||||||
if (process.env['CODER_AGENT_PORT']) {
|
if (process.env['CODER_AGENT_PORT']) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { setTargetDir } from '../config/config.js';
|
|||||||
import { getPersistedState, type PersistedTaskMetadata } from '../types.js';
|
import { getPersistedState, type PersistedTaskMetadata } from '../types.js';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
type ObjectType = 'metadata' | 'workspace';
|
type ObjectType = 'metadata' | 'workspace' | 'conversation';
|
||||||
|
|
||||||
const getTmpArchiveFilename = (taskId: string): string =>
|
const getTmpArchiveFilename = (taskId: string): string =>
|
||||||
`task-${taskId}-workspace-${uuidv4()}.tar.gz`;
|
`task-${taskId}-workspace-${uuidv4()}.tar.gz`;
|
||||||
@@ -224,6 +224,28 @@ export class GCSTaskStore implements TaskStore {
|
|||||||
`Workspace directory ${workDir} not found, skipping workspace save for task ${taskId}.`,
|
`Workspace directory ${workDir} not found, skipping workspace save for task ${taskId}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Save conversation history if present in metadata
|
||||||
|
const rawHistory = dataToStore?.['_conversationHistory'];
|
||||||
|
const conversationHistory = Array.isArray(rawHistory)
|
||||||
|
? rawHistory
|
||||||
|
: undefined;
|
||||||
|
if (conversationHistory && conversationHistory.length > 0) {
|
||||||
|
const conversationObjectPath = this.getObjectPath(
|
||||||
|
taskId,
|
||||||
|
'conversation',
|
||||||
|
);
|
||||||
|
const historyJson = JSON.stringify(conversationHistory);
|
||||||
|
const compressedHistory = gzipSync(Buffer.from(historyJson));
|
||||||
|
const conversationFile = this.storage
|
||||||
|
.bucket(this.bucketName)
|
||||||
|
.file(conversationObjectPath);
|
||||||
|
await conversationFile.save(compressedHistory, {
|
||||||
|
contentType: 'application/gzip',
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
`Task ${taskId} conversation history saved to GCS: gs://${this.bucketName}/${conversationObjectPath} (${conversationHistory.length} entries)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to save task ${taskId} to GCS:`, error);
|
logger.error(`Failed to save task ${taskId} to GCS:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -280,6 +302,29 @@ export class GCSTaskStore implements TaskStore {
|
|||||||
logger.info(`Task ${taskId} workspace archive not found in GCS.`);
|
logger.info(`Task ${taskId} workspace archive not found in GCS.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore conversation history if available
|
||||||
|
const conversationObjectPath = this.getObjectPath(taskId, 'conversation');
|
||||||
|
const conversationFile = this.storage
|
||||||
|
.bucket(this.bucketName)
|
||||||
|
.file(conversationObjectPath);
|
||||||
|
const [conversationExists] = await conversationFile.exists();
|
||||||
|
if (conversationExists) {
|
||||||
|
try {
|
||||||
|
const [compressedHistory] = await conversationFile.download();
|
||||||
|
const historyJson = gunzipSync(compressedHistory).toString();
|
||||||
|
const conversationHistory: unknown[] = JSON.parse(historyJson);
|
||||||
|
loadedMetadata['_conversationHistory'] = conversationHistory;
|
||||||
|
logger.info(
|
||||||
|
`Task ${taskId} conversation history restored from GCS (${conversationHistory.length} entries)`,
|
||||||
|
);
|
||||||
|
} catch (historyError) {
|
||||||
|
logger.warn(
|
||||||
|
`Task ${taskId} conversation history could not be restored:`,
|
||||||
|
historyError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: taskId,
|
id: taskId,
|
||||||
contextId: loadedMetadata._contextId || uuidv4(),
|
contextId: loadedMetadata._contextId || uuidv4(),
|
||||||
|
|||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Test script for the Google Chat bridge webhook endpoint.
|
||||||
|
# Simulates Google Chat events to verify the bridge works.
|
||||||
|
#
|
||||||
|
# Usage: ./test-chat-bridge.sh [PORT]
|
||||||
|
# Default port: 9090 (for kubectl port-forward)
|
||||||
|
|
||||||
|
PORT=${1:-9090}
|
||||||
|
BASE_URL="http://localhost:${PORT}"
|
||||||
|
|
||||||
|
echo "Testing chat bridge at ${BASE_URL}..."
|
||||||
|
|
||||||
|
# 1. Test health endpoint
|
||||||
|
echo -e "\n--- Health Check ---"
|
||||||
|
curl -s "${BASE_URL}/chat/health" | jq .
|
||||||
|
|
||||||
|
# 2. Test ADDED_TO_SPACE event
|
||||||
|
echo -e "\n--- ADDED_TO_SPACE ---"
|
||||||
|
curl -s -X POST "${BASE_URL}/chat/webhook" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"type": "ADDED_TO_SPACE",
|
||||||
|
"eventTime": "2026-01-01T00:00:00Z",
|
||||||
|
"space": { "name": "spaces/test123", "type": "DM" },
|
||||||
|
"user": { "name": "users/123", "displayName": "Test User" }
|
||||||
|
}' | jq .
|
||||||
|
|
||||||
|
# 3. Test MESSAGE event
|
||||||
|
echo -e "\n--- MESSAGE (Hello) ---"
|
||||||
|
curl -s -X POST "${BASE_URL}/chat/webhook" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"type": "MESSAGE",
|
||||||
|
"eventTime": "2026-01-01T00:01:00Z",
|
||||||
|
"message": {
|
||||||
|
"name": "spaces/test123/messages/msg1",
|
||||||
|
"sender": { "name": "users/123", "displayName": "Test User" },
|
||||||
|
"createTime": "2026-01-01T00:01:00Z",
|
||||||
|
"text": "Hello, write me a python hello world",
|
||||||
|
"argumentText": "Hello, write me a python hello world",
|
||||||
|
"thread": { "name": "spaces/test123/threads/thread1" },
|
||||||
|
"space": { "name": "spaces/test123", "type": "DM" }
|
||||||
|
},
|
||||||
|
"space": { "name": "spaces/test123", "type": "DM" },
|
||||||
|
"user": { "name": "users/123", "displayName": "Test User" }
|
||||||
|
}' | jq .
|
||||||
|
|
||||||
|
echo -e "\nDone."
|
||||||
Reference in New Issue
Block a user