Compare commits

..

83 Commits

Author SHA1 Message Date
Adam Weidman bb379102d6 fix: SSE keepalive, request timeout, NoOp task store caching, and Docker tooling
- Add SSE keepalive middleware (15s comment heartbeat) to prevent Cloud Run
  LB from closing idle SSE connections during long tool executions
- Increase A2A server request timeout from 300s to 3600s (1 hour)
- Cache loaded tasks in NoOpTaskStore to prevent redundant GCS workspace
  restores on every SDK event cycle (was restoring 5+ times per request)
- Add undici dispatcher with 10-min timeouts for bridge SSE connections
- Install gh CLI and gcloud CLI in Docker image for agent GitHub/GCP access
2026-02-20 10:02:13 -05:00
Adam Weidman 305a47e5b5 docs: update README known limitations for message splitting 2026-02-19 16:49:52 -05:00
Adam Weidman 9f0a2cf7b0 fix: streaming text extraction, YOLO timing, and message chunking
Streaming fixes:
- Fix A2UI text extraction preferring incremental chunks over accumulated
  text by swapping push order in extractFromStreamEvent
- Defer tool approval handling to post-stream — server YOLO mode briefly
  publishes awaiting_approval before auto-approving, which was breaking
  the stream loop prematurely
- Always update latestPendingApprovals to clear stale approvals after
  server auto-approves

Message chunking:
- Split long messages at paragraph/line boundaries to stay within Google
  Chat's 4096 character limit
- Cards attach to first chunk only

Streaming tool confirmations:
- Add sendToolConfirmationStream and sendBatchToolConfirmationsStream
  for SSE-based tool approval flow

README:
- Update bridge deployment to max-instances=1 (single instance for
  in-memory async guard consistency)
- Add Known Limitations section

Working:
- Server YOLO mode with streaming text extraction
- Chat API push with proper threading
- Session persistence across Cloud Run restarts
- Retry logic for agent concurrency exhaustion
- Text chunking for long responses
- Per-session /yolo and /safe commands

Not working:
- Tool confirmation streaming with GEMINI_YOLO_MODE=false (executor
  aborts on SSE disconnect — SDK-level issue)
- CARD_CLICKED button routing through Add-ons (text-based approval
  works as fallback)
2026-02-19 16:32:28 -05:00
Adam Weidman a9eb2ee5e3 fix: push all messages via Chat API for threading, add retry and model override
- All bot responses now pushed via Chat API instead of synchronous webhook
  response — Add-ons createMessageAction ignores thread info in Spaces,
  causing messages to appear as top-level instead of in-thread
- Webhook returns bare {} for empty responses to prevent Google Chat
  from retrying (wrapped empty createMessageAction caused retry storm)
- Add retryStream() with exponential backoff (3 retries, 5s/10s/20s)
  for transient A2A server 500/503 errors
- Add GEMINI_MODEL env var override in config (was hardcoded to
  gemini-3-pro-preview which hit capacity limits)
- Extract pushAndReturn() helper for fire-and-forget Chat API sends
2026-02-19 14:00:26 -05:00
Adam Weidman fc9623248d fix: session cancellation, YOLO auto-approval, threading, and approval UX
- Add session cancellation on /reset to stop in-flight async streams
- Implement per-session YOLO auto-approval with batch tool confirmations
  (sendBatchToolConfirmations sends all approvals in one A2A message to
  avoid hangs when agent needs ALL tools approved before proceeding)
- Fix threading: include thread info in Add-ons response wrapper so
  replies appear in the user's thread instead of top-level
- Make tool approval async: return immediate ack, process confirmation
  in background, push result via Chat API (fixes "Agent is processing..."
  empty response after approve)
- Replace text-based approval with clickable Approve/Always Allow/Reject
  buttons on compact Cards V2
- Wire CARD_CLICKED handler to async approval flow (fire-and-forget
  with UPDATE_MESSAGE ack)

Tested via Cloud Run proxy curl suite:
  /reset, simple messages, async guard, /yolo, /safe, CARD_CLICKED
  (approve + reject), ADDED_TO_SPACE, empty message, cancellation.
2026-02-19 11:42:06 -05:00
Adam Weidman 45c9545f78 fix: Cloud Run service-to-service auth and agent card URL
- Add identity token auth in A2ABridgeClient for Cloud Run (K_SERVICE)
- Support CODER_AGENT_PUBLIC_URL env var for agent card URL on Cloud Run
- Strip @Bot mention prefix before slash command detection (Add-ons)
- Grant bridge SA roles/run.invoker on A2A server via IAM
2026-02-15 00:22:08 -07:00
Adam Weidman 6872805274 feat: split chat bridge into standalone microservice
Separate the Google Chat bridge from the A2A agent server so each can
scale independently on Cloud Run. The bridge is a lightweight proxy
(concurrency=80) while the agent needs concurrency=1.

- Add standalone entry point (src/chat-bridge/server.ts)
- Add Dockerfile.chat-bridge and cloudbuild-chat-bridge.yaml
- Remove chat bridge setup from app.ts
- Inline A2UI constants in a2a-bridge-client.ts (no agent deps)
- Update README for two-service architecture
2026-02-14 20:04:32 -07:00
Adam Weidman 4f3ffc8959 feat: async streaming chat bridge with Chat API push
Replace blocking A2A calls with streaming: webhook returns
immediate "Processing..." response, then streams results from
A2A agent and pushes them to Google Chat via REST API.

- Add ChatApiClient for proactive messaging via Chat REST API
- Add sendMessageStream() to A2ABridgeClient for SSE streaming
- Add extractFromStreamEvent() for parsing individual stream events
- Refactor handler to fire-and-forget async processing
- Fix isTerminal logic to use stream state instead of taskId presence
- Add asyncProcessing guard to prevent overlapping requests
- Add comprehensive README with deployment and setup guide
2026-02-14 19:54:47 -07:00
Adam Weidman 03ef280120 fix: reply in user's thread and prevent git credential hangs
Pass thread name through Add-ons response wrapper so bot replies stay
in the user's thread instead of posting top-level messages. Add
GIT_TERMINAL_PROMPT=0 to Dockerfile to prevent git from hanging on
credential prompts, which was blocking all requests under concurrency=1.
2026-02-12 17:36:58 -05:00
Adam Weidman 60b01761b8 feat: add GCS-backed session and conversation persistence
Enable session resumability across Cloud Run restarts:

- executor.ts: Save conversation history in task metadata during
  toSDKTask(), restore via setHistory() in reconstruct()
- gcs.ts: Persist conversation history as separate GCS object
  (conversation.tar.gz) alongside metadata and workspace
- session-store.ts: Add optional GCS-backed persistence with periodic
  flush and restore-on-startup for thread→session mappings
- handler.ts: Restore persisted sessions on initialize()
- types.ts: Add gcsBucket to ChatBridgeConfig
- app.ts: Pass GCS_BUCKET_NAME to chat bridge config

Validated end-to-end: message persists, Cloud Run restarts, follow-up
message in same thread correctly recalls prior context. Different
threads maintain isolation.
2026-02-12 17:17:08 -05:00
Adam Weidman df81bfe1f2 feat: Google Chat bridge with YOLO mode, text-based approvals, and Add-ons support
- Add normalizeEvent() to convert Workspace Add-ons event format to legacy ChatEvent
- Add wrapAddOnsResponse() for Add-ons response wrapping
- Mount chat bridge routes BEFORE A2A SDK catch-all handler
- Replace card button approvals with text-based approve/reject/always allow
- Add /reset, /yolo, /safe slash commands for session control
- Add YOLO mode tool approval dedup (filter auto-approved surfaces)
- Add extractCommandSummary() for concise tool card display
- Delegate auth to Cloud Run IAM when K_SERVICE env var detected
- Add JWT debug logging for token claim inspection
2026-02-12 16:12:18 -05:00
Adam Weidman 1c926921ed fix: delegate auth to Cloud Run IAM when K_SERVICE is set
Cloud Run strips the Authorization header after using it for IAM
validation, so our JWT middleware can never see the token. When
running on Cloud Run (detected via K_SERVICE env var), skip
app-level JWT verification since Cloud Run IAM already ensures
only authorized service accounts (chat@system.gserviceaccount.com)
can reach the container.
2026-02-12 12:19:35 -05:00
Adam Weidman d1ca874dd1 fix: deduplicate agent response text from task history
Task history contains multiple status-update messages that may
reference the same A2UI surface. Use only the last non-empty
agent response to avoid duplicate text in Chat output.
2026-02-12 11:20:32 -05:00
Adam Weidman 470228e7a0 fix: extract A2UI content from task history in blocking mode
The blocking DefaultRequestHandler accumulates intermediate status-update
events into task.history. The A2UI response content from "working" events
lives there, while the final "input-required" status has no message.
Updated extractAllParts to check history. Reverted to blocking mode
since streaming had transport issues.
2026-02-12 11:10:50 -05:00
Adam Weidman 9d12980baa feat: add JWT verification middleware for Google Chat webhook
Verifies Bearer tokens from Google Chat using google-auth-library.
Checks issuer (chat@system.gserviceaccount.com) and audience
(CHAT_PROJECT_NUMBER). Verification is skipped when project number
is not configured, allowing local testing without tokens.
2026-02-12 10:56:42 -05:00
Adam Weidman b85a3bafe5 feat: switch chat bridge to streaming for full A2UI content capture
Blocking mode only returns the final task state, missing intermediate
A2UI response content from working status-update events. Streaming
captures all events and aggregates parts into the response.
2026-02-12 10:36:58 -05:00
Adam Weidman 72ea38b306 fix: resolve agent card URL for A2A client initialization
ClientFactory.createFromUrl expects the full agent card URL
(/.well-known/agent-card.json), not just the base server URL.
Also adds CHAT_BRIDGE_A2A_URL to k8s deployment and test script.
2026-02-12 10:23:47 -05:00
Adam Weidman 57f3c9ca1a feat: add Google Chat bridge for A2UI integration
Implements a Google Chat HTTP webhook bridge that connects Google Chat
to the A2A server. Each Chat thread maps to an A2A contextId/taskId
pair. The bridge converts A2UI tool approval surfaces to Google Chat
Cards V2 with Approve/Always Allow/Reject buttons, and handles
CARD_CLICKED events to forward tool confirmations back to the A2A server.

Components:
- chat-bridge/types.ts: Google Chat event/response types
- chat-bridge/session-store.ts: Thread -> A2A session mapping
- chat-bridge/a2a-bridge-client.ts: A2A SDK client wrapper
- chat-bridge/response-renderer.ts: A2UI -> Google Chat Cards V2
- chat-bridge/handler.ts: Event handler (MESSAGE, CARD_CLICKED)
- chat-bridge/routes.ts: Express routes mounted at /chat/webhook
2026-02-12 10:11:58 -05:00
Adam Weidman 5ea957c84b feat: add A2UI extension support and GKE deployment infrastructure
Implement A2UI v0.10 protocol compliance for the a2a-server:
- Add a2ui-extension.ts with constants, Part helpers, extension detection
- Add a2ui-components.ts with standard catalog component builders
- Add a2ui-surface-manager.ts for tool approval, response, thought surfaces
- Integrate A2UI into task.ts and executor.ts
- Add configurable bind host (CODER_AGENT_HOST) for k8s compatibility

Add GKE deployment infrastructure:
- Dockerfile, cloudbuild.yaml, k8s deployment manifest, .dockerignore
2026-02-11 19:23:56 -05:00
Jacob Richman f9fc9335f5 Code review cleanup for thinking display (#18720) 2026-02-10 19:12:40 +00:00
Sehoon Shon 9813531f81 Update Gemini.md to use the curent year on creating new files (#18460) 2026-02-10 19:06:17 +00:00
N. Taylor Mullen 55571de066 feat: redact disabled tools from system prompt (#13597) (#18613) 2026-02-10 19:00:36 +00:00
Jack Wotherspoon 740f0e4c3d fix: allow ask_user tool in yolo mode (#18541) 2026-02-10 18:56:51 +00:00
N. Taylor Mullen b37e67451a ci: shard windows tests and fix event listener leaks (#18670) 2026-02-10 18:46:42 +00:00
Aishanee Shah 262138cad5 test: add model-specific snapshots for coreTools (#18707)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2026-02-10 18:41:01 +00:00
Keith Guerin 5920750c24 ui: update & subdue footer colors and animate progress indicator (#18570) 2026-02-10 17:36:20 +00:00
Sandy Tao f5b1245f51 feat(cli): defer devtools startup and integrate with F12 (#18695) 2026-02-10 16:54:23 +00:00
Sri Pasumarthi f2ca0bb38d feat: add extensionsExplore setting to enable extensions explore UI. (#18686) 2026-02-10 16:49:24 +00:00
Jack Wotherspoon 4e9bafdacd chore: consolidate to green in ask user dialog (#18734) 2026-02-10 16:01:06 +00:00
N. Taylor Mullen 41bbe6ca0a fix(core): standardize tool formatting in system prompts (#18615) 2026-02-10 15:30:08 +00:00
Shreya Keshive eb5492f9b5 fix(ide): fix ide nudge setting (#18733) 2026-02-10 15:29:22 +00:00
matt korwel 37f128a109 feat(routing): restrict numerical routing to Gemini 3 family (#18478) 2026-02-10 14:25:21 +00:00
Jack Wotherspoon 79753ec5ec fix: move toasts location to left side (#18705) 2026-02-10 13:39:28 +00:00
Adib234 e151b4890b fix(plan): update persistent approval mode setting (#18638)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2026-02-10 13:07:04 +00:00
Jerop Kipruto e6b43cb846 feat(cli): update approval mode labels and shortcuts per latest UX spec (#18698) 2026-02-10 12:45:14 +00:00
N. Taylor Mullen 2ae5e1ae20 feat(core): optimize sub-agents system prompt intro (#18608) 2026-02-10 08:25:42 +00:00
N. Taylor Mullen 67d9b76e81 test(core): remove hardcoded model from TestRig (#18710) 2026-02-10 07:54:23 +00:00
N. Taylor Mullen 4a3c7ae8b7 docs(skills): enhance pr-creator safety and interactivity (#18616) 2026-02-10 05:38:10 +00:00
Christian Gunderman bd2744031f Fix layout rounding. (#18667) 2026-02-10 04:53:49 +00:00
N. Taylor Mullen 92a5f725a1 refactor(core): refine Security & System Integrity section in system prompt (#18601) 2026-02-10 04:32:36 +00:00
N. Taylor Mullen 4494f9e062 test(cli): fix AppContainer act() warnings and improve waitFor resilience (#18676) 2026-02-09 20:44:22 -08:00
N. Taylor Mullen ece001f264 fix(ui): remove redundant newlines in Gemini messages (#18538) 2026-02-10 04:18:21 +00:00
Dmitry Lyalin d3cfbdb3b7 Inline thinking bubbles with summary/full modes (#18033)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-02-10 03:24:41 +00:00
Jerop Kipruto 7a132512cf docs: remove TOC marker from Plan Mode header (#18678) 2026-02-10 03:13:04 +00:00
Spencer 6dae3a5402 Feature/quota visibility 16795 (#18203) 2026-02-10 02:53:10 +00:00
N. Taylor Mullen 0a3ecf3a75 fix(cli): Improve header spacing (#18531) 2026-02-10 02:12:42 +00:00
Jack Wotherspoon 9081743a7f feat: Ctrl+O to expand paste placeholder (#18103) 2026-02-10 02:04:34 +00:00
joshualitt 89d4556c45 feat(core): Render memory hierarchically in context. (#18350) 2026-02-10 02:01:59 +00:00
Aishanee Shah 5d0570b113 refactor(core): centralize core tool definitions and support model-specific schemas (#18662) 2026-02-10 01:29:52 +00:00
Tommaso Sciortino eb94284256 Properly parse at-commands with narrow non-breaking spaces (#18677) 2026-02-10 00:51:24 +00:00
N. Taylor Mullen cc2798018b feat: handle multiple dynamic context filenames in system prompt (#18598) 2026-02-10 00:37:08 +00:00
g-samroberts c9f9a7f67a Change event type for release (#18693) 2026-02-10 00:26:20 +00:00
Christian Gunderman fd65416a2f Disallow unsafe type assertions (#18688) 2026-02-10 00:10:15 +00:00
Gal Zahavi bce1caefd0 feat(cli): disable folder trust in headless mode (#18407) 2026-02-09 23:46:49 +00:00
Adib234 80057c5208 Fix number of lines being reported in rewind confirmation dialog (#18675) 2026-02-09 23:11:53 +00:00
Sandy Tao 14219bb57d chore: remove unused exports and redundant hook files (#18681) 2026-02-09 23:01:23 +00:00
Sandy Tao ef957a368d feat(cli): add DevTools integration with gemini-cli-devtools (#18648) 2026-02-09 22:03:10 +00:00
N. Taylor Mullen a3e5b564f7 fix(cli): correct 'esc to cancel' position and restore duration display (#18534) 2026-02-09 21:44:39 +00:00
Tommaso Sciortino 1b98c1f806 refactor: push isValidPath() into parsePastedPaths() (#18664) 2026-02-09 21:19:51 +00:00
Jerop Kipruto 9e41b2cd89 feat: enable plan mode experiment in settings (#18636) 2026-02-09 21:10:11 +00:00
Aishanee Shah 3fb1937247 refactor(core): model-dependent tool definitions (#18563) 2026-02-09 20:46:23 +00:00
Jacob Richman 07056c8f16 Harded code assist converter. (#18656) 2026-02-09 20:45:55 +00:00
joshualitt 08dca3e1d6 bug(core): Fix minor bug in migration logic. (#18661) 2026-02-09 20:41:12 +00:00
Jack Wotherspoon bcc0f27594 chore: make ask_user header description more clear (#18657) 2026-02-09 20:14:28 +00:00
Tommaso Sciortino 262e8384d4 Allow @-includes outside of workspaces (with permission) (#18470) 2026-02-09 12:24:28 -08:00
Abhijith V Ashok e73288f25f Added "" as default value, since getText() used to expect a string only and thus crashed when undefined... Fixes #18076 (#18099) 2026-02-09 12:13:12 -08:00
N. Taylor Mullen aebc107d2c feat: move shell efficiency guidelines to tool description (#18614) 2026-02-09 18:51:13 +00:00
Aswin Ashok 469cbca67f fix(chore): Support linting for cjs (#18639)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-02-09 18:36:16 +00:00
N. Taylor Mullen cb7fca01b2 docs: add legacy snippets convention to GEMINI.md (#18597) 2026-02-09 10:29:55 -08:00
christine betts 81ac5be30b Remove relative docs links (#18650) 2026-02-09 18:08:39 +00:00
Gal Zahavi 81ccd80c6d feat(cli): implement atomic writes and safety checks for trusted folders (#18406) 2026-02-09 17:16:56 +00:00
Sandy Tao 01906a9205 fix: shorten tool call IDs and fix duplicate tool name in truncated output filenames (#18600) 2026-02-09 17:09:17 +00:00
N. Taylor Mullen da66c7c0d1 chore(evals): update validation_fidelity_pre_existing_errors to USUALLY_PASSES (#18617) 2026-02-09 01:31:22 -08:00
N. Taylor Mullen fe70052baf fix(evals): update save_memory evals and simplify tool description (#18610) 2026-02-09 01:06:03 -08:00
Andrew Garrett 8cbe851339 Fix newline insertion bug in replace tool (#18595) 2026-02-09 06:37:53 +00:00
N. Taylor Mullen d45a45d565 chore: strengthen validation guidance in system prompt (#18544) 2026-02-09 05:32:46 +00:00
Jerop Kipruto 69f562b38f docs: Add Plan Mode documentation (#18582) 2026-02-09 03:25:02 +00:00
N. Taylor Mullen cb73fbf384 feat(core): transition sub-agents to XML format and improve definitions (#18555) 2026-02-09 02:25:04 +00:00
Jerop Kipruto 375c104b32 fix(core): ensure enter_plan_mode tool registration respects experimental.plan (#18587) 2026-02-09 01:57:01 +00:00
N. Taylor Mullen 97a4e62dfa feat(core): conditionally include ctrl+f prompt based on interactive shell setting (#18561) 2026-02-09 00:23:22 +00:00
Tommaso Sciortino 29a6aecffc propagate abortSignal (#18477) 2026-02-08 21:56:26 +00:00
N. Taylor Mullen 92012365ca fix(core): correct escaped interpolation in system prompt (#18557) 2026-02-08 21:08:17 +00:00
Abhi 802bcf4dee refactor(cli): switch useToolScheduler to event-driven engine (#18565) 2026-02-08 20:28:37 +00:00
448 changed files with 15911 additions and 5310 deletions
+15
View File
@@ -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/**
+5 -1
View File
@@ -2,6 +2,10 @@
"experimental": {
"toolOutputMasking": {
"enabled": true
}
},
"plan": true
},
"general": {
"devtools": true
}
}
+29 -9
View File
@@ -14,25 +14,34 @@ repository's standards.
Follow these steps to create a Pull Request:
1. **Branch Management**: Check the current branch to avoid working directly
on `main`.
1. **Branch Management**: **CRITICAL:** Ensure you are NOT working on the
`main` branch.
- Run `git branch --show-current`.
- If the current branch is `main`, create and switch to a new descriptive
branch:
- If the current branch is `main`, you MUST create and switch to a new
descriptive branch:
```bash
git checkout -b <new-branch-name>
```
2. **Locate Template**: Search for a pull request template in the repository.
2. **Commit Changes**: Verify that all intended changes are committed.
- Run `git status` to check for unstaged or uncommitted changes.
- If there are uncommitted changes, stage and commit them with a descriptive
message before proceeding. NEVER commit directly to `main`.
```bash
git add .
git commit -m "type(scope): description"
```
3. **Locate Template**: Search for a pull request template in the repository.
- Check `.github/pull_request_template.md`
- Check `.github/PULL_REQUEST_TEMPLATE.md`
- If multiple templates exist (e.g., in `.github/PULL_REQUEST_TEMPLATE/`),
ask the user which one to use or select the most appropriate one based on
the context (e.g., `bug_fix.md` vs `feature.md`).
3. **Read Template**: Read the content of the identified template file.
4. **Read Template**: Read the content of the identified template file.
4. **Draft Description**: Create a PR description that strictly follows the
5. **Draft Description**: Create a PR description that strictly follows the
template's structure.
- **Headings**: Keep all headings from the template.
- **Checklists**: Review each item. Mark with `[x]` if completed. If an item
@@ -44,14 +53,24 @@ Follow these steps to create a Pull Request:
- **Related Issues**: Link any issues fixed or related to this PR (e.g.,
"Fixes #123").
5. **Preflight Check**: Before creating the PR, run the workspace preflight
6. **Preflight Check**: Before creating the PR, run the workspace preflight
script to ensure all build, lint, and test checks pass.
```bash
npm run preflight
```
If any checks fail, address the issues before proceeding to create the PR.
6. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping
7. **Push Branch**: Push the current branch to the remote repository.
**CRITICAL SAFETY RAIL:** Double-check your branch name before pushing.
NEVER push if the current branch is `main`.
```bash
# Verify current branch is NOT main
git branch --show-current
# Push non-interactively
git push -u origin HEAD
```
8. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping
issues with multi-line Markdown, write the description to a temporary file
first.
```bash
@@ -68,6 +87,7 @@ Follow these steps to create a Pull Request:
## Principles
- **Safety First**: NEVER push to `main`. This is your highest priority.
- **Compliance**: Never ignore the PR template. It exists for a reason.
- **Completeness**: Fill out all relevant sections.
- **Accuracy**: Don't check boxes for tasks you haven't done.
+6 -2
View File
@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/no-require-imports */
/* global process, console, require */
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const { Octokit } = require('@octokit/rest');
/**
+15 -2
View File
@@ -356,11 +356,17 @@ jobs:
clean-script: 'clean'
test_windows:
name: 'Slow Test - Win'
name: 'Slow Test - Win - ${{ matrix.shard }}'
runs-on: 'gemini-cli-windows-16-core'
needs: 'merge_queue_skipper'
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
continue-on-error: true
timeout-minutes: 60
strategy:
matrix:
shard:
- 'cli'
- 'others'
steps:
- name: 'Checkout'
@@ -411,7 +417,14 @@ jobs:
NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256'
UV_THREADPOOL_SIZE: '32'
NODE_ENV: 'test'
run: 'npm run test:ci -- --coverage.enabled=false'
run: |
if ("${{ matrix.shard }}" -eq "cli") {
npm run test:ci --workspace @google/gemini-cli -- --coverage.enabled=false
} else {
# Explicitly list non-cli packages to ensure they are sharded correctly
npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present -- --coverage.enabled=false
npm run test:scripts
}
shell: 'pwsh'
- name: 'Bundle'
+1 -1
View File
@@ -4,7 +4,7 @@ name: 'Generate Release Notes'
on:
release:
types: ['created']
types: ['published']
workflow_dispatch:
inputs:
version:
+7
View File
@@ -52,6 +52,10 @@ powerful tool for developers.
## Development Conventions
- **Legacy Snippets:** `packages/core/src/prompts/snippets.legacy.ts` is a
snapshot of an older system prompt. Avoid changing the prompting verbiage to
preserve its historical behavior; however, structural changes to ensure
compilation or simplify the code are permitted.
- **Contributions:** Follow the process outlined in `CONTRIBUTING.md`. Requires
signing the Google CLA.
- **Pull Requests:** Keep PRs small, focused, and linked to an existing issue.
@@ -63,6 +67,9 @@ powerful tool for developers.
and `packages/core` (Backend logic).
- **Imports:** Use specific imports and avoid restricted relative imports
between packages (enforced by ESLint).
- **License Headers:** For all new source code files (`.ts`, `.tsx`, `.js`),
include the Apache-2.0 license header with the current year. (e.g.,
`Copyright 2026 Google LLC`). This is enforced by ESLint.
## Testing Conventions
+2
View File
@@ -23,6 +23,8 @@ overview of Gemini CLI, see the [main documentation page](../index.md).
## Advanced features
- **[Plan mode (experimental)](./plan-mode.md):** Use a safe, read-only mode for
planning complex changes.
- **[Checkpointing](./checkpointing.md):** Automatically save and restore
snapshots of your session and files.
- **[Enterprise configuration](./enterprise.md):** Deploy and manage Gemini CLI
+5 -3
View File
@@ -106,6 +106,7 @@ available combinations.
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`<br />`Ctrl + S` |
| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl + O` |
| Toggle current background shell visibility. | `Ctrl + B` |
| Toggle background shell list. | `Ctrl + L` |
| Kill the active background shell. | `Ctrl + K` |
@@ -139,6 +140,7 @@ available combinations.
single-line input, navigate backward or forward through prompt history.
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
the numbered radio option and confirm when the full number is entered.
- `Double-click` on a paste placeholder (`[Pasted Text: X lines]`) in alternate
buffer mode: Expand to view full content inline. Double-click again to
collapse.
- `Ctrl + O`: Expand or collapse paste placeholders (`[Pasted Text: X lines]`)
inline when the cursor is over the placeholder.
- `Double-click` on a paste placeholder (alternate buffer mode only): Expand to
view full content inline. Double-click again to collapse.
+106
View File
@@ -0,0 +1,106 @@
# Plan Mode (experimental)
Plan Mode is a safe, read-only mode for researching and designing complex
changes. It prevents modifications while you research, design and plan an
implementation strategy.
> **Note: Plan Mode is currently an experimental feature.**
>
> Experimental features are subject to change. To use Plan Mode, enable it via
> `/settings` (search for `Plan`) or add the following to your `settings.json`:
>
> ```json
> {
> "experimental": {
> "plan": true
> }
> }
> ```
>
> Your feedback is invaluable as we refine this feature. If you have ideas,
> suggestions, or encounter issues:
>
> - Use the `/bug` command within the CLI to file an issue.
> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues) on
> GitHub.
- [Starting in Plan Mode](#starting-in-plan-mode)
- [How to use Plan Mode](#how-to-use-plan-mode)
- [Entering Plan Mode](#entering-plan-mode)
- [The Planning Workflow](#the-planning-workflow)
- [Exiting Plan Mode](#exiting-plan-mode)
- [Tool Restrictions](#tool-restrictions)
## Starting in Plan Mode
You can configure Gemini CLI to start directly in Plan Mode by default:
1. Type `/settings` in the CLI.
2. Search for `Default Approval Mode`.
3. Set the value to `Plan`.
Other ways to start in Plan Mode:
- **CLI Flag:** `gemini --approval-mode=plan`
- **Manual Settings:** Manually update your `settings.json`:
```json
{
"general": {
"defaultApprovalMode": "plan"
}
}
```
## How to use Plan Mode
### Entering Plan Mode
You can enter Plan Mode in three ways:
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
(`Default` -> `Plan` -> `Auto-Edit`).
2. **Command:** Type `/plan` in the input box.
3. **Natural Language:** Ask the agent to "start a plan for...".
### The Planning Workflow
1. **Requirements:** The agent clarifies goals using `ask_user`.
2. **Exploration:** The agent uses read-only tools (like [`read_file`]) to map
the codebase and validate assumptions.
3. **Planning:** A detailed plan is written to a temporary Markdown file.
4. **Review:** You review the plan.
- **Approve:** Exit Plan Mode and start implementation (switching to
Auto-Edit or Default approval mode).
- **Iterate:** Provide feedback to refine the plan.
### Exiting Plan Mode
To exit Plan Mode:
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode.
1. **Tool:** The agent calls the `exit_plan_mode` tool to present the finalized
plan for your approval.
## Tool Restrictions
Plan Mode enforces strict safety policies to prevent accidental changes.
These are the only allowed tools:
- **FileSystem (Read):** [`read_file`], [`list_directory`], [`glob`]
- **Search:** [`grep_search`], [`google_web_search`]
- **Interaction:** `ask_user`
- **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`,
`postgres_read_schema`) are allowed.
- **Planning (Write):** [`write_file`] and [`replace`] ONLY allowed for `.md`
files in the `~/.gemini/tmp/<project>/plans/` directory.
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile
[`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext
[`write_file`]: /docs/tools/file-system.md#3-write_file-writefile
[`glob`]: /docs/tools/file-system.md#4-glob-findfiles
[`google_web_search`]: /docs/tools/web-search.md
[`replace`]: /docs/tools/file-system.md#6-replace-edit
[MCP tools]: /docs/tools/mcp-server.md
+16 -15
View File
@@ -22,13 +22,14 @@ they appear in the UI.
### General
| UI Label | Setting | Description | Default |
| ------------------------ | ---------------------------------- | ------------------------------------------------------------- | ------- |
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
| UI Label | Setting | Description | Default |
| ------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
### Output
@@ -43,6 +44,7 @@ they appear in the UI.
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` |
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
@@ -95,14 +97,13 @@ they appear in the UI.
### Tools
| UI Label | Setting | Description | Default |
| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
| Approval Mode | `tools.approvalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. | `40000` |
| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` |
| UI Label | Setting | Description | Default |
| -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. | `40000` |
| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` |
### Security
+21 -7
View File
@@ -106,6 +106,17 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Enable Vim keybindings
- **Default:** `false`
- **`general.defaultApprovalMode`** (enum):
- **Description:** The default approval mode for tool execution. 'default'
prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is
read-only mode. 'yolo' is not supported yet.
- **Default:** `"default"`
- **Values:** `"default"`, `"auto_edit"`, `"plan"`
- **`general.devtools`** (boolean):
- **Description:** Enable DevTools inspector on launch.
- **Default:** `false`
- **`general.enableAutoUpdate`** (boolean):
- **Description:** Enable automatic updates.
- **Default:** `true`
@@ -184,6 +195,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`ui.inlineThinkingMode`** (enum):
- **Description:** Display model thinking inline: off or full.
- **Default:** `"off"`
- **Values:** `"off"`, `"full"`
- **`ui.showStatusInTitle`** (boolean):
- **Description:** Show Gemini CLI model thoughts in the terminal window title
during the working phase
@@ -672,13 +688,6 @@ their corresponding top-level category object in your `settings.json` file.
performance.
- **Default:** `true`
- **`tools.approvalMode`** (enum):
- **Description:** The default approval mode for tool execution. 'default'
prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is
read-only mode. 'yolo' is not supported yet.
- **Default:** `"default"`
- **Values:** `"default"`, `"auto_edit"`, `"plan"`
- **`tools.core`** (array):
- **Description:** Restrict the set of built-in tools with an allowlist. Match
semantics mirror tools.allowed; see the built-in tools documentation for
@@ -855,6 +864,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.extensionRegistry`** (boolean):
- **Description:** Enable extension registry explore UI.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.extensionReloading`** (boolean):
- **Description:** Enables extension loading/unloading within the CLI session.
- **Default:** `false`
+1
View File
@@ -20,6 +20,7 @@
{ "label": "Project context (GEMINI.md)", "slug": "docs/cli/gemini-md" },
{ "label": "Shell commands", "slug": "docs/tools/shell" },
{ "label": "Session management", "slug": "docs/cli/session-management" },
{ "label": "Plan mode (experimental)", "slug": "docs/cli/plan-mode" },
{ "label": "Todos", "slug": "docs/tools/todos" },
{ "label": "Web search and fetch", "slug": "docs/tools/web-search" }
]
+1
View File
@@ -63,6 +63,7 @@ const external = [
'@lydell/node-pty-win32-arm64',
'@lydell/node-pty-win32-x64',
'keytar',
'gemini-cli-devtools',
];
const baseConfig = {
+36 -5
View File
@@ -23,6 +23,7 @@ const __dirname = path.dirname(__filename);
// Determine the monorepo root (assuming eslint.config.js is at the root)
const projectRoot = __dirname;
const currentYear = new Date().getFullYear();
export default tseslint.config(
{
@@ -37,7 +38,6 @@ export default tseslint.config(
'dist/**',
'evals/**',
'packages/test-utils/**',
'packages/core/src/skills/builtin/skill-creator/scripts/*.cjs',
],
},
eslint.configs.recommended,
@@ -193,6 +193,14 @@ export default tseslint.config(
],
},
},
{
// Rules that only apply to product code
files: ['packages/*/src/**/*.{ts,tsx}'],
ignores: ['**/*.test.ts', '**/*.test.tsx'],
rules: {
'@typescript-eslint/no-unsafe-type-assertion': 'error',
},
},
{
// Allow os.homedir() in tests and paths.ts where it is used to implement the helper
files: [
@@ -243,7 +251,7 @@ export default tseslint.config(
},
},
{
files: ['./**/*.{tsx,ts,js}'],
files: ['./**/*.{tsx,ts,js,cjs}'],
plugins: {
headers,
import: importPlugin,
@@ -260,8 +268,8 @@ export default tseslint.config(
].join('\n'),
patterns: {
year: {
pattern: '202[5-6]',
defaultValue: '2026',
pattern: `202[5-${currentYear.toString().slice(-1)}]`,
defaultValue: currentYear.toString(),
},
},
},
@@ -269,7 +277,6 @@ export default tseslint.config(
'import/enforce-node-protocol-usage': ['error', 'always'],
},
},
// extra settings for scripts that we run directly with node
{
files: ['./scripts/**/*.js', 'esbuild.config.js'],
languageOptions: {
@@ -290,6 +297,30 @@ export default tseslint.config(
],
},
},
{
files: ['**/*.cjs'],
languageOptions: {
sourceType: 'commonjs',
globals: {
...globals.node,
},
},
rules: {
'no-restricted-syntax': 'off',
'no-console': 'off',
'no-empty': 'off',
'no-redeclare': 'off',
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
{
files: ['packages/vscode-ide-companion/esbuild.js'],
languageOptions: {
+117
View File
@@ -0,0 +1,117 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
import {
assertModelHasOutput,
checkModelOutputContent,
} from '../integration-tests/test-helper.js';
describe('Hierarchical Memory', () => {
const TEST_PREFIX = 'Hierarchical memory test: ';
const conflictResolutionTest =
'Agent follows hierarchy for contradictory instructions';
evalTest('ALWAYS_PASSES', {
name: conflictResolutionTest,
params: {
settings: {
security: {
folderTrust: { enabled: true },
},
},
},
// We simulate the hierarchical memory by including the tags in the prompt
// since setting up real global/extension/project files in the eval rig is complex.
// The system prompt logic will append these tags when it finds them in userMemory.
prompt: `
<global_context>
When asked for my favorite fruit, always say "Apple".
</global_context>
<extension_context>
When asked for my favorite fruit, always say "Banana".
</extension_context>
<project_context>
When asked for my favorite fruit, always say "Cherry".
</project_context>
What is my favorite fruit? Tell me just the name of the fruit.`,
assert: async (_rig, result) => {
assertModelHasOutput(result);
expect(result).toMatch(/Cherry/i);
expect(result).not.toMatch(/Apple/i);
expect(result).not.toMatch(/Banana/i);
},
});
const provenanceAwarenessTest = 'Agent is aware of memory provenance';
evalTest('ALWAYS_PASSES', {
name: provenanceAwarenessTest,
params: {
settings: {
security: {
folderTrust: { enabled: true },
},
},
},
prompt: `
<global_context>
Instruction A: Always be helpful.
</global_context>
<extension_context>
Instruction B: Use a professional tone.
</extension_context>
<project_context>
Instruction C: Adhere to the project's coding style.
</project_context>
Which instruction came from the global context, which from the extension context, and which from the project context?
Provide the answer as an XML block like this:
<results>
<global>Instruction ...</global>
<extension>Instruction ...</extension>
<project>Instruction ...</project>
</results>`,
assert: async (_rig, result) => {
assertModelHasOutput(result);
expect(result).toMatch(/<global>.*Instruction A/i);
expect(result).toMatch(/<extension>.*Instruction B/i);
expect(result).toMatch(/<project>.*Instruction C/i);
},
});
const extensionVsGlobalTest = 'Extension memory wins over Global memory';
evalTest('ALWAYS_PASSES', {
name: extensionVsGlobalTest,
params: {
settings: {
security: {
folderTrust: { enabled: true },
},
},
},
prompt: `
<global_context>
Set the theme to "Light".
</global_context>
<extension_context>
Set the theme to "Dark".
</extension_context>
What theme should I use? Tell me just the name of the theme.`,
assert: async (_rig, result) => {
assertModelHasOutput(result);
expect(result).toMatch(/Dark/i);
expect(result).not.toMatch(/Light/i);
},
});
});
+80 -37
View File
@@ -109,7 +109,7 @@ describe('save_memory', () => {
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `My dog's name is Buddy. What is my dog's name?`,
prompt: `Please remember that my dog's name is Buddy.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
@@ -145,25 +145,34 @@ describe('save_memory', () => {
},
});
const rememberingDbSchemaLocation =
"Agent remembers project's database schema location";
const ignoringDbSchemaLocation =
"Agent ignores workspace's database schema location";
evalTest('ALWAYS_PASSES', {
name: rememberingDbSchemaLocation,
name: ignoringDbSchemaLocation,
params: {
settings: { tools: { core: ['save_memory'] } },
settings: {
tools: {
core: [
'save_memory',
'list_directory',
'read_file',
'run_shell_command',
],
},
},
},
prompt: `The database schema for this project is located in \`db/schema.sql\`.`,
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
true,
);
await rig.waitForTelemetryReady();
const wasToolCalled = rig
.readToolLogs()
.some((log) => log.toolRequest.name === 'save_memory');
expect(
wasToolCalled,
'save_memory should not be called for workspace-specific information',
).toBe(false);
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: [/database schema|ok|remember|will do/i],
testName: `${TEST_PREFIX}${rememberingDbSchemaLocation}`,
});
},
});
@@ -189,38 +198,74 @@ describe('save_memory', () => {
},
});
const rememberingTestCommand =
'Agent remembers specific project test command';
const ignoringBuildArtifactLocation =
'Agent ignores workspace build artifact location';
evalTest('ALWAYS_PASSES', {
name: rememberingTestCommand,
name: ignoringBuildArtifactLocation,
params: {
settings: { tools: { core: ['save_memory'] } },
settings: {
tools: {
core: [
'save_memory',
'list_directory',
'read_file',
'run_shell_command',
],
},
},
},
prompt: `The command to run all backend tests is \`npm run test:backend\`.`,
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
true,
);
await rig.waitForTelemetryReady();
const wasToolCalled = rig
.readToolLogs()
.some((log) => log.toolRequest.name === 'save_memory');
expect(
wasToolCalled,
'save_memory should not be called for workspace-specific information',
).toBe(false);
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: [
/command to run all backend tests|ok|remember|will do/i,
],
testName: `${TEST_PREFIX}${rememberingTestCommand}`,
});
},
});
const rememberingMainEntryPoint =
"Agent remembers project's main entry point";
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
evalTest('ALWAYS_PASSES', {
name: rememberingMainEntryPoint,
name: ignoringMainEntryPoint,
params: {
settings: {
tools: {
core: [
'save_memory',
'list_directory',
'read_file',
'run_shell_command',
],
},
},
},
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
const wasToolCalled = rig
.readToolLogs()
.some((log) => log.toolRequest.name === 'save_memory');
expect(
wasToolCalled,
'save_memory should not be called for workspace-specific information',
).toBe(false);
assertModelHasOutput(result);
},
});
const rememberingBirthday = "Agent remembers user's birthday";
evalTest('ALWAYS_PASSES', {
name: rememberingBirthday,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `The main entry point for this project is \`src/index.js\`.`,
prompt: `My birthday is on June 15th.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
@@ -229,10 +274,8 @@ describe('save_memory', () => {
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: [
/main entry point for this project|ok|remember|will do/i,
],
testName: `${TEST_PREFIX}${rememberingMainEntryPoint}`,
expectedContent: [/June 15th|ok|remember|will do/i],
testName: `${TEST_PREFIX}${rememberingBirthday}`,
});
},
});
+110
View File
@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('Shell Efficiency', () => {
const getCommand = (call: any): string | undefined => {
let args = call.toolRequest.args;
if (typeof args === 'string') {
try {
args = JSON.parse(args);
} catch (e) {
// Ignore parse errors
}
}
return typeof args === 'string' ? args : (args as any)['command'];
};
evalTest('USUALLY_PASSES', {
name: 'should use --silent/--quiet flags when installing packages',
prompt: 'Install the "lodash" package using npm.',
assert: async (rig) => {
const toolCalls = rig.readToolLogs();
const shellCalls = toolCalls.filter(
(call) => call.toolRequest.name === 'run_shell_command',
);
const hasEfficiencyFlag = shellCalls.some((call) => {
const cmd = getCommand(call);
return (
cmd &&
cmd.includes('npm install') &&
(cmd.includes('--silent') ||
cmd.includes('--quiet') ||
cmd.includes('-q'))
);
});
expect(
hasEfficiencyFlag,
`Expected agent to use efficiency flags for npm install. Commands used: ${shellCalls
.map(getCommand)
.join(', ')}`,
).toBe(true);
},
});
evalTest('USUALLY_PASSES', {
name: 'should use --no-pager with git commands',
prompt: 'Show the git log.',
assert: async (rig) => {
const toolCalls = rig.readToolLogs();
const shellCalls = toolCalls.filter(
(call) => call.toolRequest.name === 'run_shell_command',
);
const hasNoPager = shellCalls.some((call) => {
const cmd = getCommand(call);
return cmd && cmd.includes('git') && cmd.includes('--no-pager');
});
expect(
hasNoPager,
`Expected agent to use --no-pager with git. Commands used: ${shellCalls
.map(getCommand)
.join(', ')}`,
).toBe(true);
},
});
evalTest('USUALLY_PASSES', {
name: 'should NOT use efficiency flags when enableShellOutputEfficiency is disabled',
params: {
settings: {
tools: {
shell: {
enableShellOutputEfficiency: false,
},
},
},
},
prompt: 'Install the "lodash" package using npm.',
assert: async (rig) => {
const toolCalls = rig.readToolLogs();
const shellCalls = toolCalls.filter(
(call) => call.toolRequest.name === 'run_shell_command',
);
const hasEfficiencyFlag = shellCalls.some((call) => {
const cmd = getCommand(call);
return (
cmd &&
cmd.includes('npm install') &&
(cmd.includes('--silent') ||
cmd.includes('--quiet') ||
cmd.includes('-q'))
);
});
expect(
hasEfficiencyFlag,
'Agent used efficiency flags even though enableShellOutputEfficiency was disabled',
).toBe(false);
},
});
});
+2 -2
View File
@@ -49,7 +49,7 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
// bootstrap test projects.
const rootNodeModules = path.join(process.cwd(), 'node_modules');
const testNodeModules = path.join(rig.testDir || '', 'node_modules');
if (fs.existsSync(rootNodeModules)) {
if (fs.existsSync(rootNodeModules) && !fs.existsSync(testNodeModules)) {
fs.symlinkSync(rootNodeModules, testNodeModules, 'dir');
}
@@ -162,7 +162,7 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
if (policy === 'USUALLY_PASSES' && !process.env['RUN_EVALS']) {
it.skip(evalCase.name, fn);
} else {
it(evalCase.name, fn);
it(evalCase.name, fn, evalCase.timeout);
}
}
+85
View File
@@ -0,0 +1,85 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('validation_fidelity', () => {
evalTest('ALWAYS_PASSES', {
name: 'should perform exhaustive validation autonomously when guided by system instructions',
files: {
'src/types.ts': `
export interface LogEntry {
level: 'info' | 'warn' | 'error';
message: string;
}
`,
'src/logger.ts': `
import { LogEntry } from './types.js';
export function formatLog(entry: LogEntry): string {
return \`[\${entry.level.toUpperCase()}] \${entry.message}\`;
}
`,
'src/logger.test.ts': `
import { expect, test } from 'vitest';
import { formatLog } from './logger.js';
import { LogEntry } from './types.js';
test('formats log correctly', () => {
const entry: LogEntry = { level: 'info', message: 'test message' };
expect(formatLog(entry)).toBe('[INFO] test message');
});
`,
'package.json': JSON.stringify({
name: 'test-project',
type: 'module',
scripts: {
test: 'vitest run',
build: 'tsc --noEmit',
},
}),
'tsconfig.json': JSON.stringify({
compilerOptions: {
target: 'ESNext',
module: 'ESNext',
moduleResolution: 'node',
strict: true,
esModuleInterop: true,
skipLibCheck: true,
forceConsistentCasingInFileNames: true,
},
}),
},
prompt:
"Refactor the 'LogEntry' interface in 'src/types.ts' to rename the 'message' field to 'payload'.",
timeout: 600000,
assert: async (rig) => {
// The goal of this eval is to see if the agent realizes it needs to update usages
// AND run 'npm run build' or 'tsc' autonomously to ensure project-wide structural integrity.
const toolLogs = rig.readToolLogs();
const shellCalls = toolLogs.filter(
(log) => log.toolRequest.name === 'run_shell_command',
);
const hasBuildOrTsc = shellCalls.some((log) => {
const cmd = JSON.parse(log.toolRequest.args).command.toLowerCase();
return (
cmd.includes('npm run build') ||
cmd.includes('tsc') ||
cmd.includes('typecheck') ||
cmd.includes('npm run verify')
);
});
expect(
hasBuildOrTsc,
'Expected the agent to autonomously run a build or type-check command to verify the refactoring',
).toBe(true);
},
});
});
@@ -0,0 +1,79 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('validation_fidelity_pre_existing_errors', () => {
evalTest('USUALLY_PASSES', {
name: 'should handle pre-existing project errors gracefully during validation',
files: {
'src/math.ts': `
export function add(a: number, b: number): number {
return a + b;
}
`,
'src/index.ts': `
import { add } from './math.js';
console.log(add(1, 2));
`,
'src/utils.ts': `
export function multiply(a: number, b: number): number {
return a * c; // 'c' is not defined - PRE-EXISTING ERROR
}
`,
'package.json': JSON.stringify({
name: 'test-project',
type: 'module',
scripts: {
test: 'vitest run',
build: 'tsc --noEmit',
},
}),
'tsconfig.json': JSON.stringify({
compilerOptions: {
target: 'ESNext',
module: 'ESNext',
moduleResolution: 'node',
strict: true,
esModuleInterop: true,
skipLibCheck: true,
forceConsistentCasingInFileNames: true,
},
}),
},
prompt: "In src/math.ts, rename the 'add' function to 'sum'.",
timeout: 600000,
assert: async (rig) => {
const toolLogs = rig.readToolLogs();
const replaceCalls = toolLogs.filter(
(log) => log.toolRequest.name === 'replace',
);
// Verify it did the work in math.ts
const mathRefactor = replaceCalls.some((log) => {
const args = JSON.parse(log.toolRequest.args);
return (
args.file_path.endsWith('src/math.ts') &&
args.new_string.includes('sum')
);
});
expect(mathRefactor, 'Agent should have refactored math.ts').toBe(true);
const shellCalls = toolLogs.filter(
(log) => log.toolRequest.name === 'run_shell_command',
);
const ranValidation = shellCalls.some((log) => {
const cmd = JSON.parse(log.toolRequest.args).command.toLowerCase();
return cmd.includes('build') || cmd.includes('tsc');
});
expect(ranValidation, 'Agent should have attempted validation').toBe(
true,
);
},
});
});
+3
View File
@@ -20,5 +20,8 @@ export default defineConfig({
maxThreads: 16,
},
},
env: {
GEMINI_TEST_TYPE: 'integration',
},
},
});
+41 -1
View File
@@ -31,6 +31,7 @@
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/shell-quote": "^1.7.5",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^3.1.1",
"@vitest/eslint-plugin": "^1.3.4",
"cross-env": "^7.0.3",
@@ -75,6 +76,7 @@
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0",
"gemini-cli-devtools": "^0.2.1",
"keytar": "^7.9.0",
"node-pty": "^1.0.0"
}
@@ -2253,6 +2255,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2433,6 +2436,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2466,6 +2470,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2834,6 +2839,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2867,6 +2873,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1"
@@ -2919,6 +2926,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1",
@@ -4134,6 +4142,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4428,6 +4437,7 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5420,6 +5430,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -8429,6 +8440,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8969,6 +8981,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -9604,6 +9617,18 @@
"node": ">=14"
}
},
"node_modules/gemini-cli-devtools": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/gemini-cli-devtools/-/gemini-cli-devtools-0.2.1.tgz",
"integrity": "sha512-PcqPL9ZZjgjsp3oYhcXnUc6yNeLvdZuU/UQp0aT+DA8pt3BZzPzXthlOmIrRRqHBdLjMLPwN5GD29zR5bASXtQ==",
"optional": true,
"dependencies": {
"ws": "^8.16.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/gemini-cli-vscode-ide-companion": {
"resolved": "packages/vscode-ide-companion",
"link": true
@@ -10570,6 +10595,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
"integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -14354,6 +14380,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14364,6 +14391,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -16600,6 +16628,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16823,7 +16852,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16831,6 +16861,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -17003,6 +17034,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -17210,6 +17242,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -17323,6 +17356,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17335,6 +17369,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -18039,6 +18074,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -18061,6 +18097,7 @@
"@google/gemini-cli-core": "file:../core",
"express": "^5.1.0",
"fs-extra": "^11.3.0",
"google-auth-library": "^9.11.0",
"tar": "^7.5.2",
"uuid": "^13.0.0",
"winston": "^3.17.0"
@@ -18138,6 +18175,7 @@
"mnemonist": "^0.40.3",
"open": "^10.1.2",
"prompts": "^2.4.2",
"proper-lockfile": "^4.1.2",
"react": "^19.2.0",
"read-package-up": "^11.0.0",
"shell-quote": "^1.8.3",
@@ -18241,6 +18279,7 @@
"mnemonist": "^0.40.3",
"open": "^10.1.2",
"picomatch": "^4.0.1",
"proper-lockfile": "^4.1.2",
"read-package-up": "^11.0.0",
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
@@ -18335,6 +18374,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
+2
View File
@@ -90,6 +90,7 @@
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/shell-quote": "^1.7.5",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^3.1.1",
"@vitest/eslint-plugin": "^1.3.4",
"cross-env": "^7.0.3",
@@ -137,6 +138,7 @@
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0",
"gemini-cli-devtools": "^0.2.1",
"keytar": "^7.9.0",
"node-pty": "^1.0.0"
},
+40
View File
@@ -0,0 +1,40 @@
# 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 gpg apt-transport-https \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg \
| gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" \
> /etc/apt/sources.list.d/google-cloud-sdk.list \
&& apt-get update && apt-get install -y --no-install-recommends gh google-cloud-cli \
&& 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,23 @@
# Standalone Google Chat bridge server.
# Connects to the A2A agent server over HTTP — no agent dependencies needed.
FROM docker.io/library/node:20-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy pre-installed node_modules and pre-built dist
COPY package.json package-lock.json ./
COPY node_modules/ node_modules/
COPY packages/a2a-server/ packages/a2a-server/
USER node
ENV PORT=8080
ENV NODE_ENV=production
EXPOSE 8080
CMD ["node", "packages/a2a-server/dist/src/chat-bridge/server.js"]
+353 -2
View File
@@ -1,5 +1,356 @@
# Gemini CLI A2A Server
## All code in this package is experimental and under active development
> **Experimental** - This package is under active development.
This package contains the A2A server implementation for the Gemini CLI.
An [A2A (Agent-to-Agent)](https://google.github.io/A2A/) server that wraps the
Gemini CLI agent, enabling remote interaction via the A2A protocol. Includes a
Google Chat bridge for using the agent directly from Google Chat.
## Architecture
```
Google Chat ──webhook──> Chat Bridge ──A2A──> A2A Server ──> Gemini CLI Agent
└── Chat REST API (push responses back to Chat)
```
This package contains two independently deployable services:
1. **A2A Server** (`src/http/server.ts`) - Standard A2A protocol endpoint
(JSON-RPC + SSE streaming) that wraps the Gemini CLI agent. Heavy workload —
deploy with `concurrency=1`.
2. **Chat Bridge** (`src/chat-bridge/server.ts`) - Lightweight proxy that
translates Google Chat webhooks into A2A protocol calls. Connects to the A2A
server over HTTP. Deploy with high concurrency (`concurrency=80`).
The Chat Bridge responds immediately to webhooks with "Processing..." (avoiding
Google Chat's 30s timeout), then streams results from the A2A agent and pushes
them to Chat via the REST API.
## Prerequisites
- **GCP project** with the following APIs enabled:
- Cloud Run API
- Cloud Build API
- Artifact Registry API
- Google Chat API
- Cloud Storage API (for session persistence)
- **gcloud CLI** authenticated with your project
- **Node.js 20+** for local development
- **Gemini API key** from [Google AI Studio](https://aistudio.google.com/)
## Environment Variables
### A2A Server
| Variable | Required | Description |
| ---------------------------- | -------- | -------------------------------------------------------------- |
| `GEMINI_API_KEY` | Yes | Gemini API key for the agent |
| `CODER_AGENT_PORT` | No | Server port (default: `8080`) |
| `CODER_AGENT_HOST` | No | Bind host (default: `localhost`, set `0.0.0.0` for containers) |
| `CODER_AGENT_WORKSPACE_PATH` | No | Agent workspace directory (default: `/workspace`) |
| `GCS_BUCKET_NAME` | No | GCS bucket for task persistence |
| `GEMINI_YOLO_MODE` | No | Set `true` to auto-approve all tool calls |
| `GIT_TERMINAL_PROMPT` | No | Set `0` to prevent git credential prompts in headless env |
### Chat Bridge
| Variable | Required | Description |
| --------------------- | -------- | -------------------------------------------------------------- |
| `A2A_SERVER_URL` | Yes | URL of the A2A agent server (e.g. `http://localhost:8080`) |
| `PORT` | No | Server port (default: `8080`) |
| `CHAT_PROJECT_NUMBER` | No | Google Chat project number for JWT verification |
| `CHAT_SA_KEY_PATH` | No | Path to service account key for Chat API (uses ADC if not set) |
| `GCS_BUCKET_NAME` | No | GCS bucket for session persistence |
| `CHAT_BRIDGE_DEBUG` | No | Set `true` for verbose bridge logging |
## Local Development
### Build
From the repo root:
```bash
npm install
npm run build
```
### Run the A2A Server
```bash
export GEMINI_API_KEY="your-api-key"
export CODER_AGENT_PORT=8080
node packages/a2a-server/dist/src/http/server.js
```
### Run the Chat Bridge (separate terminal)
```bash
export A2A_SERVER_URL=http://localhost:8080
export PORT=8090
node packages/a2a-server/dist/src/chat-bridge/server.js
```
### Test the A2A endpoint
```bash
# Check the agent card
curl http://localhost:8080/.well-known/agent-card.json | jq .
# Send a message (JSON-RPC)
curl -X POST http://localhost:8080 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"kind": "message",
"role": "user",
"messageId": "test-1",
"parts": [{"kind": "text", "text": "Hello, what can you do?"}]
},
"configuration": {"blocking": true}
}
}'
```
### Test the Chat Bridge
```bash
# Health check
curl http://localhost:8080/chat/health | jq .
# Simulate a Google Chat MESSAGE event
curl -X POST http://localhost:8080/chat/webhook \
-H "Content-Type: application/json" \
-d '{
"type": "MESSAGE",
"eventTime": "2026-01-01T00:00:00Z",
"message": {
"name": "spaces/test/messages/1",
"text": "Hello agent",
"thread": {"name": "spaces/test/threads/abc"},
"sender": {"name": "users/1", "displayName": "Test User"},
"space": {"name": "spaces/test", "type": "DM"}
},
"space": {"name": "spaces/test", "type": "DM"},
"user": {"name": "users/1", "displayName": "Test User"}
}'
```
## Cloud Run Deployment
### 1. Create Artifact Registry repository
```bash
export PROJECT_ID=your-project-id
export REGION=us-central1
gcloud artifacts repositories create gemini-a2a \
--repository-format=docker \
--location=$REGION \
--project=$PROJECT_ID
```
### 2. Create GCS bucket (optional, for session persistence)
```bash
gsutil mb -l $REGION gs://gemini-a2a-sessions-$PROJECT_ID
```
### 3. Build both images
```bash
# Build A2A agent server
gcloud builds submit \
--config=packages/a2a-server/cloudbuild.yaml \
--project=$PROJECT_ID
# Build Chat bridge
gcloud builds submit \
--config=packages/a2a-server/cloudbuild-chat-bridge.yaml \
--project=$PROJECT_ID
```
### 4. Deploy A2A agent server
```bash
export AGENT_IMAGE=us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/a2a-server:latest
gcloud run deploy gemini-a2a-server \
--image=$AGENT_IMAGE \
--region=$REGION \
--project=$PROJECT_ID \
--platform=managed \
--allow-unauthenticated \
--memory=2Gi \
--cpu=2 \
--timeout=300 \
--concurrency=1 \
--max-instances=1 \
--set-env-vars="GEMINI_YOLO_MODE=true,GCS_BUCKET_NAME=gemini-a2a-sessions-$PROJECT_ID" \
--set-secrets="GEMINI_API_KEY=gemini-api-key:latest"
```
### 5. Deploy Chat bridge
Get the A2A server URL first:
```bash
export A2A_URL=$(gcloud run services describe gemini-a2a-server \
--region=$REGION --project=$PROJECT_ID --format='value(status.url)')
export BRIDGE_IMAGE=us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/chat-bridge:latest
gcloud run deploy gemini-chat-bridge \
--image=$BRIDGE_IMAGE \
--region=$REGION \
--project=$PROJECT_ID \
--platform=managed \
--allow-unauthenticated \
--memory=512Mi \
--cpu=1 \
--timeout=60 \
--concurrency=80 \
--max-instances=1 \
--set-env-vars="A2A_SERVER_URL=$A2A_URL,GCS_BUCKET_NAME=gemini-a2a-sessions-$PROJECT_ID"
```
> **Important**: After initial deployment, always use `--update-env-vars`
> instead of `--set-env-vars` to avoid wiping existing environment variables.
### 6. Update an existing deployment
```bash
# Update env vars without replacing existing ones
gcloud run services update gemini-a2a-server \
--region=$REGION \
--project=$PROJECT_ID \
--update-env-vars="NEW_VAR=value"
# Deploy a new image
gcloud run services update gemini-a2a-server \
--region=$REGION \
--project=$PROJECT_ID \
--image=$IMAGE
```
## Google Chat App Configuration
### 1. Create a service account for Chat API
The Chat bridge needs a service account with the Chat API scope to push messages
proactively.
```bash
# Create service account
gcloud iam service-accounts create gemini-chat-bot \
--display-name="Gemini Chat Bot" \
--project=$PROJECT_ID
# Download key (for local dev)
gcloud iam service-accounts keys create chat-sa-key.json \
--iam-account=gemini-chat-bot@$PROJECT_ID.iam.gserviceaccount.com
```
On Cloud Run, use Application Default Credentials (ADC) instead of a key file.
Grant the Cloud Run service account the `chat.bot` scope by configuring it as
the Chat app's service account.
### 2. Configure the Google Chat app
1. Go to
[Google Cloud Console > APIs & Services > Google Chat API > Configuration](https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat)
2. Set **App name** and **Description**
3. Under **Connection settings**, select **HTTP endpoint URL**
4. Set the URL to your **Chat bridge** Cloud Run service URL + `/chat/webhook`:
```
https://gemini-chat-bridge-HASH-uc.a.run.app/chat/webhook
```
5. Under **Authentication Audience**, select **HTTP endpoint URL**
6. Under **Visibility**, choose who can use the app
7. Under **Permissions**, configure who can install it
8. Click **Save**
### 3. Grant Cloud Run invoker permission
If your Cloud Run service requires authentication (recommended):
```bash
# Get the Chat service account
# It's usually chat@system.gserviceaccount.com
gcloud run services add-iam-policy-binding gemini-chat-bridge \
--region=$REGION \
--project=$PROJECT_ID \
--member="serviceAccount:chat@system.gserviceaccount.com" \
--role="roles/run.invoker"
```
## Chat Bridge Commands
When messaging the bot in Google Chat:
| Command | Description |
| ----------------------- | --------------------------------------------------- |
| `/reset` or `reset` | Clear the current session and start fresh |
| `/yolo` | Enable YOLO mode - auto-approve all tool calls |
| `/safe` | Disable YOLO mode - require approval for tool calls |
| `approve` / `yes` / `y` | Approve a pending tool call |
| `reject` / `no` / `n` | Reject a pending tool call |
| `always allow` | Approve and always allow this tool |
## Troubleshooting
### "Gemini CLI Agent is not responding" in Google Chat
This usually means the bridge couldn't return "Processing..." within Google
Chat's 30-second timeout. Check Cloud Run logs for both services:
```bash
# Chat bridge logs
gcloud run services logs read gemini-chat-bridge \
--region=$REGION --project=$PROJECT_ID --limit=50
# A2A agent logs
gcloud run services logs read gemini-a2a-server \
--region=$REGION --project=$PROJECT_ID --limit=50
```
### Tool approvals appearing in YOLO mode
Ensure `GEMINI_YOLO_MODE=true` is set. If you used `--set-env-vars` during a
deployment, it may have wiped this variable. Use `--update-env-vars` instead.
### Agent hangs on git operations
The `GIT_TERMINAL_PROMPT=0` env var (set in the Dockerfile) prevents git from
prompting for credentials. If git operations require authentication, configure a
credential helper or use `gh auth` with a token.
### Session state lost after restart
Enable GCS persistence by setting `GCS_BUCKET_NAME`. Sessions are automatically
flushed to GCS every 30 seconds and restored on startup.
### Chat responses appear as top-level messages instead of thread replies
The Chat bridge includes `thread.name` in all responses. If replies still appear
at the top level, ensure the webhook event includes thread information. DM
conversations always thread correctly; spaces may need threading enabled.
## Known Limitations
- **Google Chat 4096 character limit**: Long agent responses are automatically
split into multiple messages at paragraph/line boundaries.
- **Single bridge instance**: The bridge uses `max-instances=1` so that the
in-memory async processing guard works correctly. This means no redundancy
during deploys (brief downtime during revision rollover).
- **Tool confirmation in streaming mode**: When the A2A server has
`GEMINI_YOLO_MODE=false`, tool confirmations via streaming may not return text
due to an SDK-level issue (executor aborts on SSE disconnect). Server YOLO
mode works correctly.
@@ -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 for Chat bridge
- name: 'gcr.io/cloud-builders/docker'
args:
- 'build'
- '-t'
- 'us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/chat-bridge:latest'
- '-f'
- 'packages/a2a-server/Dockerfile.chat-bridge'
- '.'
# Step 3: Push to Artifact Registry
- name: 'gcr.io/cloud-builders/docker'
args:
- 'push'
- 'us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/chat-bridge:latest'
images:
- 'us-central1-docker.pkg.dev/$PROJECT_ID/gemini-a2a/chat-bridge:latest'
timeout: '1800s'
options:
machineType: 'E2_HIGHCPU_8'
+35
View File
@@ -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'
+74
View File
@@ -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
+1
View File
@@ -29,6 +29,7 @@
"@google-cloud/storage": "^7.16.0",
"@google/gemini-cli-core": "file:../core",
"express": "^5.1.0",
"google-auth-library": "^9.11.0",
"fs-extra": "^11.3.0",
"tar": "^7.5.2",
"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);
}
+64 -1
View File
@@ -36,6 +36,10 @@ import { loadExtensions } from '../config/extension.js';
import { Task } from './task.js';
import { requestStorage } from '../http/requestStorage.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.
@@ -73,6 +77,24 @@ class TaskWrapper {
artifacts: [],
};
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;
}
}
@@ -117,6 +139,7 @@ export class CoderAgentExecutor implements AgentExecutor {
const agentSettings = persistedState._agentSettings;
const config = await this.getConfig(agentSettings, sdkTask.id);
const contextId: string =
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(metadata['_contextId'] as string) || sdkTask.contextId;
const runtimeTask = await Task.create(
sdkTask.id,
@@ -126,7 +149,25 @@ export class CoderAgentExecutor implements AgentExecutor {
agentSettings.autoExecute,
);
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);
this.tasks.set(sdkTask.id, wrapper);
@@ -140,6 +181,7 @@ export class CoderAgentExecutor implements AgentExecutor {
agentSettingsInput?: AgentSettings,
eventBus?: ExecutionEventBus,
): Promise<TaskWrapper> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentSettings = agentSettingsInput || ({} as AgentSettings);
const config = await this.getConfig(agentSettings, taskId);
const runtimeTask = await Task.create(
@@ -290,6 +332,7 @@ export class CoderAgentExecutor implements AgentExecutor {
const contextId: string =
userMessage.contextId ||
sdkTask?.contextId ||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(sdkTask?.metadata?.['_contextId'] as string) ||
uuidv4();
@@ -385,6 +428,7 @@ export class CoderAgentExecutor implements AgentExecutor {
}
} else {
logger.info(`[CoderAgentExecutor] Creating new task ${taskId}.`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentSettings = userMessage.metadata?.[
'coderAgent'
] as AgentSettings;
@@ -431,6 +475,22 @@ export class CoderAgentExecutor implements AgentExecutor {
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)) {
logger.warn(
`[CoderAgentExecutor] Attempted to execute task ${taskId} which is already in state ${currentTask.taskState}. Ignoring.`,
@@ -548,6 +608,9 @@ export class CoderAgentExecutor implements AgentExecutor {
logger.info(
`[CoderAgentExecutor] Task ${taskId}: Agent turn finished, setting to input-required.`,
);
// Finalize A2UI surfaces before marking complete
currentTask.finalizeA2UISurfaces();
const stateChange: StateChange = {
kind: CoderAgentEvent.StateChangeEvent,
};
+211 -7
View File
@@ -56,6 +56,15 @@ import type {
Citation,
} from '../types.js';
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;
@@ -75,6 +84,11 @@ export class Task {
promptCount = 0;
autoExecute: boolean;
// A2UI support
a2uiEnabled = false;
private accumulatedText = '';
private a2uiResponseSurfaceCreated = false;
// For tool waiting logic
private pendingToolCalls: Map<string, string> = new Map(); //toolCallId --> status
private toolCompletionPromise?: Promise<void>;
@@ -378,6 +392,7 @@ export class Task {
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
this.pendingToolConfirmationDetails.set(
tc.request.callId,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
tc.confirmationDetails as ToolCallConfirmationDetails,
);
}
@@ -390,6 +405,44 @@ export class Task {
: { kind: CoderAgentEvent.ToolCallUpdateEvent };
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(
this.taskState,
coderAgentMessage,
@@ -411,7 +464,7 @@ export class Task {
);
toolCalls.forEach((tc: ToolCall) => {
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
// eslint-disable-next-line @typescript-eslint/no-floating-promises, @typescript-eslint/no-unsafe-type-assertion
(tc.confirmationDetails as ToolCallConfirmationDetails).onConfirm(
ToolConfirmationOutcome.ProceedOnce,
);
@@ -465,12 +518,14 @@ export class Task {
T extends ToolCall | AnyDeclarativeTool,
K extends UnionKeys<T>,
>(from: T, ...fields: K[]): Partial<T> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const ret = {} as Pick<T, K>;
for (const field of fields) {
if (field in from) {
ret[field] = from[field];
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return ret as Partial<T>;
}
@@ -493,6 +548,7 @@ export class Task {
);
if (tc.tool) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
serializableToolCall.tool = this._pickFields(
tc.tool,
'name',
@@ -622,8 +678,11 @@ export class Task {
request.args['new_string']
) {
const newContent = await this.getProposedContent(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
request.args['file_path'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
request.args['old_string'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
request.args['new_string'] as string,
);
return { ...request, args: { ...request.args, newContent } };
@@ -719,6 +778,7 @@ export class Task {
case GeminiEventType.Error:
default: {
// Block scope for lexical declaration
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const errorEvent = event as ServerGeminiErrorEvent; // Type assertion
const errorMessage =
errorEvent.value?.error.message ?? 'Unknown error from LLM stream';
@@ -807,6 +867,7 @@ export class Task {
if (confirmationDetails.type === 'edit') {
const payload = part.data['newContent']
? ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
newContent: part.data['newContent'] as string,
} as ToolConfirmationPayload)
: undefined;
@@ -945,7 +1006,66 @@ export class Task {
let anyConfirmationHandled = 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) {
// 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);
if (confirmationHandled) {
anyConfirmationHandled = true;
@@ -1011,6 +1131,33 @@ export class Task {
}
logger.info('[Task] Sending text content to event bus.');
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 = {
kind: CoderAgentEvent.TextContentEvent,
};
@@ -1032,15 +1179,35 @@ export class Task {
return;
}
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 = {
kind: 'message',
role: 'agent',
parts: [
{
kind: 'data',
data: content,
} as Part,
],
parts,
messageId: uuidv4(),
taskId: this.id,
contextId: this.contextId,
@@ -1061,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) {
if (!citation || citation.trim() === '') {
return;
@@ -0,0 +1,527 @@
/**
* @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,
TaskStatusUpdateEvent,
TaskArtifactUpdateEvent,
} from '@a2a-js/sdk';
import {
type Client,
ClientFactory,
ClientFactoryOptions,
DefaultAgentCardResolver,
RestTransportFactory,
JsonRpcTransportFactory,
} from '@a2a-js/sdk/client';
import { GoogleAuth } from 'google-auth-library';
import { Agent } from 'undici';
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../utils/logger.js';
/**
* Undici agent with long timeouts for SSE streaming.
* Default body/headers timeouts are ~30s which kills idle SSE connections
* when the agent runs long tools (npm install, tsc builds, etc.).
*/
const sseDispatcher = new Agent({
bodyTimeout: 10 * 60 * 1000, // 10 minutes
headersTimeout: 10 * 60 * 1000,
keepAliveTimeout: 10 * 60 * 1000,
});
// Inline A2UI constants so the chat bridge has no dependency on ../a2ui/
const A2UI_EXTENSION_URI = 'https://a2ui.org/a2a-extension/a2ui/v0.10';
const A2UI_MIME_TYPE = 'application/json+a2ui';
export type A2AResponse = Message | Task;
export type A2AStreamEventData =
| Message
| Task
| TaskStatusUpdateEvent
| TaskArtifactUpdateEvent;
/**
* 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.
* On Cloud Run (K_SERVICE is set), wraps fetch with an identity token
* for service-to-service authentication.
*/
async initialize(): Promise<void> {
if (this.client) return;
// Create fetch wrapper with long SSE timeouts.
// On Cloud Run, also add identity tokens for service-to-service auth.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const baseFetch = (input: any, init?: any) =>
fetch(input, { ...init, dispatcher: sseDispatcher });
let fetchImpl: typeof fetch = baseFetch;
if (process.env['K_SERVICE']) {
const auth = new GoogleAuth();
const idTokenClient = await auth.getIdTokenClient(this.agentUrl);
fetchImpl = async (input, init?) => {
const authHeaders = await idTokenClient.getRequestHeaders();
const merged = new Headers(init?.headers);
for (const [key, value] of Object.entries(authHeaders)) {
merged.set(key, value);
}
return baseFetch(input, { ...init, headers: merged });
};
logger.info(
'[ChatBridge] Using Cloud Run identity token for A2A server auth',
);
}
const resolver = new DefaultAgentCardResolver({ fetchImpl });
const options = ClientFactoryOptions.createFrom(
ClientFactoryOptions.default,
{
transports: [
new RestTransportFactory({ fetchImpl }),
new JsonRpcTransportFactory({ fetchImpl }),
],
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 text message and returns a streaming async generator.
* Each yielded event is a Message, Task, TaskStatusUpdateEvent,
* or TaskArtifactUpdateEvent.
*/
sendMessageStream(
text: string,
options: { contextId?: string; taskId?: string },
): AsyncGenerator<A2AStreamEventData, void, undefined> {
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,
metadata: {
extensions: [A2UI_EXTENSION_URI],
},
},
};
return this.client.sendMessageStream(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);
}
/**
* Sends a tool confirmation via streaming (SSE) so the caller can
* follow the full task lifecycle after approval.
*/
sendToolConfirmationStream(
callId: string,
outcome: string,
taskId: string,
options: { contextId?: string },
): AsyncGenerator<A2AStreamEventData, void, undefined> {
if (!this.client) {
throw new Error('A2A client not initialized. Call initialize() first.');
}
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],
},
},
};
return this.client.sendMessageStream(params);
}
/**
* Sends multiple tool confirmations in a single A2A message.
* Needed when the agent requests multiple tool approvals at once —
* sending them one at a time with blocking mode would hang because
* the agent waits for ALL approvals before proceeding.
*/
async sendBatchToolConfirmations(
approvals: Array<{ callId: string; outcome: string; taskId: string }>,
options: { contextId?: string },
): Promise<A2AResponse> {
if (!this.client) {
throw new Error('A2A client not initialized. Call initialize() first.');
}
const parts: Part[] = approvals.map(
(approval) =>
({
kind: 'data',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
data: [
{
version: 'v0.10',
action: {
name: 'tool_confirmation',
surfaceId: `tool_approval_${approval.taskId}_${approval.callId}`,
sourceComponentId:
approval.outcome === 'cancel'
? 'reject_button'
: 'approve_button',
timestamp: new Date().toISOString(),
context: {
callId: approval.callId,
outcome: approval.outcome,
taskId: approval.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,
contextId: options.contextId,
taskId: approvals[0]?.taskId,
metadata: {
extensions: [A2UI_EXTENSION_URI],
},
},
configuration: {
blocking: true,
},
};
return this.client.sendMessage(params);
}
/**
* Sends batch tool confirmations via streaming (SSE) so the caller can
* follow the full task lifecycle after approval. Returns an async generator
* that yields events until the task reaches a terminal state.
*/
sendBatchToolConfirmationsStream(
approvals: Array<{ callId: string; outcome: string; taskId: string }>,
options: { contextId?: string },
): AsyncGenerator<A2AStreamEventData, void, undefined> {
if (!this.client) {
throw new Error('A2A client not initialized. Call initialize() first.');
}
const parts: Part[] = approvals.map(
(approval) =>
({
kind: 'data',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
data: [
{
version: 'v0.10',
action: {
name: 'tool_confirmation',
surfaceId: `tool_approval_${approval.taskId}_${approval.callId}`,
sourceComponentId:
approval.outcome === 'cancel'
? 'reject_button'
: 'approve_button',
timestamp: new Date().toISOString(),
context: {
callId: approval.callId,
outcome: approval.outcome,
taskId: approval.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,
contextId: options.contextId,
taskId: approvals[0]?.taskId,
metadata: {
extensions: [A2UI_EXTENSION_URI],
},
},
};
return this.client.sendMessageStream(params);
}
}
@@ -0,0 +1,212 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Google Chat REST API client for sending proactive messages.
* Used to push agent responses back to Google Chat after the webhook
* has already returned an immediate acknowledgment.
*/
import { GoogleAuth } from 'google-auth-library';
import type { ChatCardV2 } from './types.js';
import { logger } from '../utils/logger.js';
const CHAT_API_BASE = 'https://chat.googleapis.com/v1';
/** Google Chat max text length. Leave margin for formatting overhead. */
const MAX_TEXT_LENGTH = 4000;
export interface ChatApiClientConfig {
/** Path to service account key JSON file. If not set, uses ADC. */
serviceAccountKeyPath?: string;
}
export class ChatApiClient {
private auth: GoogleAuth;
private initialized = false;
constructor(config?: ChatApiClientConfig) {
this.auth = new GoogleAuth({
keyFile: config?.serviceAccountKeyPath,
scopes: ['https://www.googleapis.com/auth/chat.bot'],
});
}
async initialize(): Promise<void> {
if (this.initialized) return;
await this.auth.getClient();
this.initialized = true;
logger.info('[ChatApiClient] Initialized with chat.bot scope');
}
/**
* Sends a new message to a Google Chat space in a specific thread.
* Automatically splits text longer than 4000 chars into multiple messages.
*/
async sendMessage(
spaceName: string,
threadName: string,
options: { text?: string; cardsV2?: ChatCardV2[] },
): Promise<string | undefined> {
if (!this.initialized) await this.initialize();
const chunks = options.text ? splitText(options.text) : [''];
// First chunk gets the cards (if any). Subsequent chunks are text-only.
let lastMessageName: string | undefined;
for (let i = 0; i < chunks.length; i++) {
const message: Record<string, unknown> = {};
if (chunks[i]) message['text'] = chunks[i];
if (i === 0 && options.cardsV2) message['cardsV2'] = options.cardsV2;
message['thread'] = { name: threadName };
const name = await this.postMessage(spaceName, message);
if (name) lastMessageName = name;
}
return lastMessageName;
}
/** Posts a single message to the Chat API. */
private async postMessage(
spaceName: string,
message: Record<string, unknown>,
): Promise<string | undefined> {
try {
const url =
`${CHAT_API_BASE}/${spaceName}/messages` +
`?messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`;
const client = await this.auth.getClient();
const headers = await client.getRequestHeaders();
const response = await fetch(url, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(message),
});
if (!response.ok) {
const body = await response.text();
logger.error(
`[ChatApiClient] sendMessage failed: ${response.status} ${body}`,
);
return undefined;
}
const result: unknown = await response.json();
let messageName: string | undefined;
if (typeof result === 'object' && result !== null && 'name' in result) {
const rec = result as Record<string, unknown>;
if (typeof rec['name'] === 'string') {
messageName = rec['name'];
}
}
logger.info(
`[ChatApiClient] Message sent to ${spaceName}: ${messageName ?? 'unknown'}`,
);
return messageName;
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
logger.error(`[ChatApiClient] sendMessage error: ${msg}`, error);
return undefined;
}
}
/**
* Updates an existing message in Google Chat.
*/
async updateMessage(
messageName: string,
options: { text?: string; cardsV2?: ChatCardV2[] },
): Promise<void> {
try {
if (!this.initialized) await this.initialize();
const message: Record<string, unknown> = {};
const updateMasks: string[] = [];
if (options.text) {
message['text'] = options.text;
updateMasks.push('text');
}
if (options.cardsV2) {
message['cardsV2'] = options.cardsV2;
updateMasks.push('cardsV2');
}
const url = `${CHAT_API_BASE}/${messageName}?updateMask=${updateMasks.join(',')}`;
const client = await this.auth.getClient();
const headers = await client.getRequestHeaders();
const response = await fetch(url, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(message),
});
if (!response.ok) {
const body = await response.text();
logger.error(
`[ChatApiClient] updateMessage failed: ${response.status} ${body}`,
);
} else {
logger.info(`[ChatApiClient] Message updated: ${messageName}`);
}
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
logger.error(`[ChatApiClient] updateMessage error: ${msg}`, error);
}
}
}
/**
* Splits text into chunks that fit within Google Chat's character limit.
* Splits on paragraph boundaries (double newline) first, then single
* newlines, then hard-splits as a last resort.
*/
function splitText(text: string): string[] {
if (text.length <= MAX_TEXT_LENGTH) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > MAX_TEXT_LENGTH) {
let splitAt = -1;
// Try splitting at a paragraph boundary
const paraIdx = remaining.lastIndexOf('\n\n', MAX_TEXT_LENGTH);
if (paraIdx > MAX_TEXT_LENGTH * 0.3) {
splitAt = paraIdx + 2; // include the double newline in the first chunk
}
// Fall back to single newline
if (splitAt < 0) {
const lineIdx = remaining.lastIndexOf('\n', MAX_TEXT_LENGTH);
if (lineIdx > MAX_TEXT_LENGTH * 0.3) {
splitAt = lineIdx + 1;
}
}
// Hard split as last resort
if (splitAt < 0) {
splitAt = MAX_TEXT_LENGTH;
}
chunks.push(remaining.substring(0, splitAt));
remaining = remaining.substring(splitAt);
}
if (remaining) chunks.push(remaining);
return chunks;
}
@@ -0,0 +1,841 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Google Chat webhook handler.
* Responds immediately with "Processing..." and streams results
* from the A2A server, pushing updates to Chat via the REST API.
*/
import type { ChatEvent, ChatResponse, ChatBridgeConfig } from './types.js';
import type { SessionInfo } from './session-store.js';
import { SessionStore } from './session-store.js';
import {
A2ABridgeClient,
type A2AStreamEventData,
} from './a2a-bridge-client.js';
import { ChatApiClient } from './chat-api-client.js';
import { renderResponse, extractFromStreamEvent } from './response-renderer.js';
import { logger } from '../utils/logger.js';
const TERMINAL_STATES = new Set([
'completed',
'failed',
'canceled',
'rejected',
]);
export class ChatBridgeHandler {
private sessionStore: SessionStore;
private a2aClient: A2ABridgeClient;
private chatApiClient: ChatApiClient;
private initialized = false;
/** Full webhook URL for card button actions (HTTP Add-ons need a URL, not a function name). */
private webhookUrl: string | undefined;
constructor(
private config: ChatBridgeConfig,
chatApiClient?: ChatApiClient,
) {
this.sessionStore = new SessionStore(config.gcsBucket);
this.a2aClient = new A2ABridgeClient(config.a2aServerUrl);
this.chatApiClient =
chatApiClient ??
new ChatApiClient({
serviceAccountKeyPath: config.serviceAccountKeyPath,
});
// For HTTP Add-ons, card button action.function must be a full HTTPS URL.
// Set CHAT_WEBHOOK_URL env var to the bridge's public webhook endpoint.
this.webhookUrl = process.env['CHAT_WEBHOOK_URL'] || undefined;
if (this.webhookUrl) {
logger.info(`[ChatBridge] Button action URL: ${this.webhookUrl}`);
}
}
/**
* Initializes the A2A client connection, Chat API client,
* and restores persisted sessions. Must be called before handling events.
*/
async initialize(): Promise<void> {
if (this.initialized) return;
await this.a2aClient.initialize();
await this.chatApiClient.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.' };
}
}
/**
* Pushes a text message via Chat API (properly threaded) and returns
* an empty webhook response. Add-ons createMessageAction ignores
* thread info and always creates a top-level message in Spaces,
* so ALL user-visible messages must go through the Chat API.
*/
private pushAndReturn(
spaceName: string,
threadName: string,
text: string,
): ChatResponse {
this.chatApiClient
.sendMessage(spaceName, threadName, { text })
.catch((err) => {
const msg = err instanceof Error ? err.message : 'Unknown error';
logger.warn(`[ChatBridge] Failed to push message: ${msg}`);
});
return {};
}
/**
* Handles a MESSAGE event: user sent a text message in Chat.
* All responses are pushed via Chat API for proper threading in Spaces.
* The webhook always returns an empty response.
*/
private async handleMessage(event: ChatEvent): Promise<ChatResponse> {
const message = event.message;
if (!message?.thread?.name) {
return { text: 'Error: Missing thread information.' };
}
// argumentText has bot mentions stripped (legacy format only).
// For Add-ons format, strip leading @mention manually.
const rawText = message.argumentText || message.text || '';
const text = rawText.replace(/^@\S+\s*/, '');
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 — push response via Chat API for threading
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 this.pushAndReturn(
spaceName,
threadName,
'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 this.pushAndReturn(
spaceName,
threadName,
'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 this.pushAndReturn(
spaceName,
threadName,
'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
if (session.pendingToolApproval && this.isToolApprovalText(trimmed)) {
return this.handleToolApprovalText(event, session, trimmed);
}
// Guard against overlapping async requests
if (session.asyncProcessing) {
return this.pushAndReturn(
spaceName,
threadName,
'Still processing your previous request. Please wait...',
);
}
// Fire-and-forget async processing
this.processMessageAsync(event, session, text).catch((err) => {
const msg = err instanceof Error ? err.message : 'Unknown error';
logger.error(`[ChatBridge] Async processing failed: ${msg}`, err);
});
// Push "Processing..." via Chat API for proper threading
return this.pushAndReturn(
spaceName,
threadName,
'_Processing your request..._',
);
}
/**
* Checks if text is a tool approval command.
*/
private isToolApprovalText(text: string): boolean {
return (
text === 'approve' ||
text === 'yes' ||
text === 'y' ||
text === 'reject' ||
text === 'no' ||
text === 'n' ||
text === 'always allow'
);
}
/**
* Handles text-based tool approval responses.
* Returns an immediate acknowledgment and processes the confirmation
* asynchronously, pushing the agent's response via Chat API.
*/
private handleToolApprovalText(
event: ChatEvent,
session: SessionInfo,
trimmed: string,
): ChatResponse {
const message = event.message!;
const threadName = message.thread.name;
const approval = session.pendingToolApproval!;
const isReject =
trimmed === 'reject' || trimmed === 'no' || trimmed === 'n';
const isAlwaysAllow = trimmed === '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;
// Fire-and-forget async processing of the tool confirmation
this.processToolApprovalAsync(event, session, approval, outcome).catch(
(err) => {
const msg = err instanceof Error ? err.message : 'Unknown error';
logger.error(
`[ChatBridge] Tool approval async processing failed: ${msg}`,
err,
);
},
);
const ackText = isReject
? '_Tool rejected._'
: '_Tool approved, processing..._';
return this.pushAndReturn(event.space.name, threadName, ackText);
}
/**
* Processes a tool confirmation asynchronously.
* Sends the confirmation to the A2A server, handles the response,
* and pushes results to Google Chat via the REST API.
*/
private async processToolApprovalAsync(
event: ChatEvent,
session: SessionInfo,
approval: { callId: string; taskId: string; toolName: string },
outcome: string,
): Promise<void> {
const message = event.message!;
const threadName = message.thread.name;
const spaceName = event.space.name;
session.asyncProcessing = true;
try {
// Stream the tool confirmation to collect text as it arrives
const stream = this.a2aClient.sendToolConfirmationStream(
approval.callId,
outcome,
approval.taskId,
{ contextId: session.contextId },
);
let lastText = '';
let lastTaskId: string | undefined;
let lastContextId: string | undefined;
let lastState: string | undefined;
let sentResponse = false;
for await (const streamEvent of stream) {
if (session.cancelled) break;
const extracted = extractFromStreamEvent(streamEvent);
if (extracted.taskId) lastTaskId = extracted.taskId;
if (extracted.contextId) lastContextId = extracted.contextId;
if (extracted.state) lastState = extracted.state;
if (extracted.text) lastText = extracted.text;
// Check for new tool approvals
const pending = extracted.toolApprovals.filter(
(a) => a.status === 'awaiting_approval',
);
if (pending.length > 0) {
if (session.yoloMode) {
// YOLO: auto-approve via streaming
const autoResult = await this.autoApproveTools(
session,
pending,
lastContextId,
);
if (autoResult.lastContextId)
lastContextId = autoResult.lastContextId;
if (autoResult.lastTaskId) lastTaskId = autoResult.lastTaskId;
if (autoResult.lastState) lastState = autoResult.lastState;
if (autoResult.text) lastText = autoResult.text;
} else {
// Non-YOLO: push approval card
session.pendingToolApproval = {
callId: pending[0].callId,
taskId: pending[0].taskId,
toolName: pending[0].displayName || pending[0].name,
};
// Build a minimal task response for the card renderer
const cardResponse = renderResponse(
{
kind: 'task',
id: pending[0].taskId,
contextId: lastContextId ?? session.contextId,
status: {
state: 'input-required',
timestamp: new Date().toISOString(),
message:
streamEvent.kind === 'status-update'
? streamEvent.status?.message
: undefined,
},
history: [],
artifacts: [],
},
message.thread.threadKey || threadName,
threadName,
this.webhookUrl,
);
await this.chatApiClient.sendMessage(spaceName, threadName, {
text: cardResponse.text,
cardsV2: cardResponse.cardsV2,
});
sentResponse = true;
logger.info(
`[ChatBridge] Pushed approval card after confirmation: ${pending[0].displayName || pending[0].name}`,
);
}
break;
}
}
if (session.cancelled) return;
// Update session IDs
if (lastContextId) session.contextId = lastContextId;
const isTerminal = lastState ? TERMINAL_STATES.has(lastState) : false;
this.sessionStore.updateTaskId(
threadName,
isTerminal ? undefined : lastTaskId,
);
// Push final text if we haven't already pushed a card
if (lastText && !sentResponse) {
await this.chatApiClient.sendMessage(spaceName, threadName, {
text: lastText,
});
logger.info(
`[ChatBridge] Pushed post-approval response (${lastText.length} chars): "${lastText.substring(0, 200)}"`,
);
}
} catch (error) {
if (session.cancelled) return;
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
logger.error(
`[ChatBridge] Error in tool approval async: ${errorMsg}`,
error,
);
await this.chatApiClient.sendMessage(spaceName, threadName, {
text: `Error processing tool confirmation: ${errorMsg}`,
});
} finally {
session.asyncProcessing = false;
}
}
/**
* Processes a message asynchronously using A2A streaming.
* Pushes results to Google Chat via the REST API.
*/
private async processMessageAsync(
event: ChatEvent,
session: SessionInfo,
text: string,
): Promise<void> {
const message = event.message!;
const threadName = message.thread.name;
const spaceName = event.space.name;
session.asyncProcessing = true;
try {
// Retry streaming if the A2A server returns 500 (no available instance).
// With concurrency=1, this happens when another request is in-flight.
const stream = await this.retryStream(
() =>
this.a2aClient.sendMessageStream(text, {
contextId: session.contextId,
taskId: session.taskId,
}),
session,
);
let lastText = '';
let lastTaskId: string | undefined;
let lastContextId: string | undefined;
let lastState: string | undefined;
let sentFinalResponse = false;
let eventCount = 0;
// Track the latest pending approvals across events — only act on them
// when the server signals input-required (meaning it actually needs input).
// In server YOLO mode, tools are auto-approved so the stream continues
// past the brief 'awaiting_approval' status without hitting input-required.
let latestPendingApprovals: Array<{
callId: string;
taskId: string;
name: string;
displayName: string;
}> = [];
let approvalStatusMessage: unknown;
for await (const streamEvent of stream) {
// Check if session was cancelled (e.g. by /reset)
if (session.cancelled) {
logger.info(
`[ChatBridge] Session cancelled, stopping stream for ${threadName}`,
);
break;
}
eventCount++;
const extracted = extractFromStreamEvent(streamEvent);
if (extracted.taskId) lastTaskId = extracted.taskId;
if (extracted.contextId) lastContextId = extracted.contextId;
if (extracted.state) lastState = extracted.state;
// Log each event for debugging
logger.info(
`[ChatBridge] Stream event #${eventCount}: kind=${streamEvent.kind}, ` +
`state=${extracted.state ?? 'n/a'}, text=${extracted.text.length} chars, ` +
`approvals=${extracted.toolApprovals.filter((a) => a.status === 'awaiting_approval').length}`,
);
// Track latest text content
if (extracted.text) {
lastText = extracted.text;
}
// Track tool approvals — always update with latest state so
// stale approvals are cleared when the server auto-approves.
const pending = extracted.toolApprovals.filter(
(a) => a.status === 'awaiting_approval',
);
latestPendingApprovals = pending;
if (pending.length > 0) {
approvalStatusMessage =
streamEvent.kind === 'status-update'
? streamEvent.status?.message
: undefined;
}
// On terminal or input-required state, stop streaming.
// input-required means the server is asking for user action
// (tool approval or follow-up message).
if (
extracted.state &&
(TERMINAL_STATES.has(extracted.state) ||
extracted.state === 'input-required')
) {
break;
}
}
logger.info(
`[ChatBridge] Stream complete: ${eventCount} events, ` +
`state=${lastState ?? 'none'}, text=${lastText.length} chars, ` +
`pendingApprovals=${latestPendingApprovals.length}`,
);
// Handle pending approvals (only relevant when server sent input-required)
if (latestPendingApprovals.length > 0 && lastState === 'input-required') {
if (session.yoloMode) {
// Bridge YOLO mode: auto-approve all tools
const autoApproved = await this.autoApproveTools(
session,
latestPendingApprovals,
lastContextId,
);
if (autoApproved.lastContextId)
lastContextId = autoApproved.lastContextId;
if (autoApproved.lastTaskId) lastTaskId = autoApproved.lastTaskId;
if (autoApproved.lastState) lastState = autoApproved.lastState;
if (autoApproved.text) lastText = autoApproved.text;
} else {
// Non-YOLO: push approval card and wait for user input
const firstApproval = latestPendingApprovals[0];
session.pendingToolApproval = {
callId: firstApproval.callId,
taskId: firstApproval.taskId,
toolName: firstApproval.displayName || firstApproval.name,
};
const approvalResponse = renderResponse(
{
kind: 'task',
id: firstApproval.taskId,
contextId: lastContextId ?? session.contextId,
status: {
state: 'input-required',
timestamp: new Date().toISOString(),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
message: approvalStatusMessage as
| import('@a2a-js/sdk').Message
| undefined,
},
history: [],
artifacts: [],
},
message.thread.threadKey || threadName,
threadName,
this.webhookUrl,
);
await this.chatApiClient.sendMessage(spaceName, threadName, {
text: approvalResponse.text,
cardsV2: approvalResponse.cardsV2,
});
sentFinalResponse = true;
logger.info(
`[ChatBridge] Pushed tool approval card: ${firstApproval.displayName || firstApproval.name}`,
);
}
}
// If session was cancelled, don't push any messages
if (session.cancelled) {
logger.info(
`[ChatBridge] Skipping response push for cancelled session ${threadName}`,
);
return;
}
// Update session IDs
if (lastContextId) session.contextId = lastContextId;
// Clear taskId on terminal states so next message starts a fresh task
const isTerminal = lastState ? TERMINAL_STATES.has(lastState) : false;
this.sessionStore.updateTaskId(
threadName,
isTerminal ? undefined : lastTaskId,
);
// Push final text response if we haven't already pushed a tool approval
if (lastText && !sentFinalResponse) {
await this.chatApiClient.sendMessage(spaceName, threadName, {
text: lastText,
});
logger.info(
`[ChatBridge] Pushed final response (${lastText.length} chars): "${lastText.substring(0, 200)}"`,
);
} else if (!lastText && !sentFinalResponse) {
await this.chatApiClient.sendMessage(spaceName, threadName, {
text: '_Agent completed without generating a response._',
});
}
} catch (error) {
if (session.cancelled) return; // Don't push errors for cancelled sessions
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
logger.error(`[ChatBridge] Async processing error: ${errorMsg}`, error);
await this.chatApiClient.sendMessage(spaceName, threadName, {
text: `Sorry, I encountered an error: ${errorMsg}`,
});
} finally {
session.asyncProcessing = false;
}
}
/**
* Retries creating a stream when the A2A server returns 500.
* Cloud Run returns 500 "no available instance" when concurrency is
* exhausted. We retry with exponential backoff up to 3 times.
*/
private async retryStream(
createStream: () => AsyncGenerator<A2AStreamEventData, void, undefined>,
session: SessionInfo,
): Promise<AsyncGenerator<A2AStreamEventData, void, undefined>> {
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 5000;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
if (session.cancelled) return createStream(); // will be caught by caller
try {
const stream = createStream();
// Try to get the first value to verify the stream connects
const iter = stream[Symbol.asyncIterator]();
const first = await iter.next();
// Re-wrap into an async generator that yields the first value
// then delegates to the rest of the iterator
async function* replayStream(): AsyncGenerator<
A2AStreamEventData,
void,
undefined
> {
if (!first.done) {
yield first.value;
yield* { [Symbol.asyncIterator]: () => iter };
}
}
return replayStream();
} catch (error) {
const msg = error instanceof Error ? error.message : '';
const isRetryable = msg.includes('500') || msg.includes('503');
if (!isRetryable || attempt === MAX_RETRIES) throw error;
const delay = BASE_DELAY_MS * Math.pow(2, attempt);
logger.warn(
`[ChatBridge] A2A server unavailable, retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES})`,
);
await new Promise((r) => setTimeout(r, delay));
}
}
// Should not reach here, but just in case
return createStream();
}
/**
* Auto-approves tool calls in YOLO mode using streaming.
* Sends approvals and collects streamed text from the SSE response.
* The A2A server streams text incrementally and closes with final:true
* at input-required (more tools) or completed (done).
*/
private async autoApproveTools(
session: SessionInfo,
initialApprovals: Array<{
callId: string;
taskId: string;
name: string;
displayName: string;
}>,
contextId: string | undefined,
): Promise<{
lastContextId?: string;
lastTaskId?: string;
lastState?: string;
text?: string;
}> {
let approvalsToProcess = initialApprovals;
let lastContextId = contextId;
let lastTaskId: string | undefined;
let lastState: string | undefined;
let lastText: string | undefined;
const approvedNames: string[] = [];
const MAX_ROUNDS = 20;
for (let round = 0; round < MAX_ROUNDS && !session.cancelled; round++) {
if (approvalsToProcess.length === 0) break;
for (const a of approvalsToProcess) {
const label = a.displayName || a.name;
logger.info(`[ChatBridge] YOLO auto-approving: ${label}`);
approvedNames.push(label);
}
// Stream tool confirmations — text arrives incrementally via SSE
const stream = this.a2aClient.sendBatchToolConfirmationsStream(
approvalsToProcess.map((a) => ({
callId: a.callId,
outcome: 'proceed_once',
taskId: a.taskId,
})),
{ contextId: lastContextId ?? session.contextId },
);
approvalsToProcess = [];
// Consume the stream, collecting text and detecting new tool approvals
let eventCount = 0;
for await (const event of stream) {
if (session.cancelled) break;
eventCount++;
const extracted = extractFromStreamEvent(event);
if (extracted.taskId) lastTaskId = extracted.taskId;
if (extracted.contextId) lastContextId = extracted.contextId;
if (extracted.state) lastState = extracted.state;
if (extracted.text) lastText = extracted.text;
logger.info(
`[ChatBridge] YOLO event #${eventCount}: kind=${event.kind}, ` +
`state=${extracted.state ?? 'n/a'}, text=${extracted.text.length} chars`,
);
// New tool approvals → break to send them in next round
const pending = extracted.toolApprovals.filter(
(a) => a.status === 'awaiting_approval',
);
if (pending.length > 0) {
approvalsToProcess = pending;
break;
}
}
logger.info(
`[ChatBridge] YOLO round ${round}: state=${lastState ?? 'none'}, ` +
`text=${lastText?.length ?? 0} chars, newApprovals=${approvalsToProcess.length}`,
);
if (lastState && TERMINAL_STATES.has(lastState)) break;
}
logger.info(
`[ChatBridge] YOLO auto-approved ${approvedNames.length} tools: ${approvedNames.join(', ')}`,
);
return { lastContextId, lastTaskId, lastState, text: lastText };
}
/**
* Handles a CARD_CLICKED event: user clicked a button on a card.
* Fires async processing and returns an immediate UPDATE_MESSAGE ack.
*/
private handleCardClicked(event: ChatEvent): 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' ||
action.actionMethodName === this.webhookUrl
) {
const params = 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.' };
}
const isReject = outcome === 'cancel';
const toolName = session.pendingToolApproval?.toolName ?? 'Tool';
// Clear pending approval tracked for text-based flow
session.pendingToolApproval = undefined;
// Fire-and-forget async processing
this.processToolApprovalAsync(
event,
session,
{ callId, taskId, toolName },
outcome,
).catch((err) => {
const msg = err instanceof Error ? err.message : 'Unknown error';
logger.error(`[ChatBridge] Card click async failed: ${msg}`, err);
});
// Update the card in-place with an acknowledgment
return {
actionResponse: { type: 'UPDATE_MESSAGE' },
text: isReject
? `*${toolName} — Rejected*`
: `*${toolName} — Approved, processing...*`,
};
}
return { text: `Unknown action: ${action.actionMethodName}` };
}
/**
* 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}`);
return {};
}
}
@@ -0,0 +1,543 @@
/**
* @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, ChatWidget } from './types.js';
import type { Part } from '@a2a-js/sdk';
import {
type A2AResponse,
type A2AStreamEventData,
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,
webhookUrl?: 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, webhookUrl));
}
}
// 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 compact Google Chat Card V2
* with clickable Approve/Reject buttons.
*/
function renderToolApprovalCard(
approval: ToolApprovalInfo,
webhookUrl?: string,
): ChatCardV2 {
const widgets: ChatWidget[] = [];
const toolLabel = approval.displayName || approval.name;
// Show a concise summary of what the tool will do.
const commandSummary = extractCommandSummary(approval);
if (commandSummary) {
widgets.push({
decoratedText: {
text: `\`${commandSummary}\``,
topLabel: toolLabel,
startIcon: { knownIcon: 'DESCRIPTION' },
wrapText: true,
},
});
} else if (approval.args && approval.args !== 'No arguments') {
const truncatedArgs =
approval.args.length > 200
? approval.args.substring(0, 200) + '...'
: approval.args;
widgets.push({
decoratedText: {
text: truncatedArgs,
topLabel: toolLabel,
startIcon: { knownIcon: 'DESCRIPTION' },
wrapText: true,
},
});
}
// Clickable buttons for approve/reject
widgets.push({
buttonList: {
buttons: [
{
text: 'Approve',
onClick: {
action: {
function: webhookUrl ?? 'tool_confirmation',
parameters: [
{ key: 'callId', value: approval.callId },
{ key: 'outcome', value: 'proceed_once' },
{ key: 'taskId', value: approval.taskId },
],
},
},
},
{
text: 'Always Allow',
onClick: {
action: {
function: webhookUrl ?? 'tool_confirmation',
parameters: [
{ key: 'callId', value: approval.callId },
{ key: 'outcome', value: 'proceed_always_tool' },
{ key: 'taskId', value: approval.taskId },
],
},
},
},
{
text: 'Reject',
onClick: {
action: {
function: webhookUrl ?? 'tool_confirmation',
parameters: [
{ key: 'callId', value: approval.callId },
{ key: 'outcome', value: 'cancel' },
{ key: 'taskId', value: approval.taskId },
],
},
},
color: { red: 0.8, green: 0.2, blue: 0.2 },
},
],
},
});
return {
cardId: `tool_approval_${approval.callId}`,
card: {
header: {
title: toolLabel,
subtitle: 'Approval Required',
},
sections: [{ widgets }],
},
};
}
/**
* Extracts text and tool approval info from a single streaming event.
* Works with TaskStatusUpdateEvent, Task, and Message events.
*/
export function extractFromStreamEvent(event: A2AStreamEventData): {
text: string;
toolApprovals: ToolApprovalInfo[];
state?: string;
taskId?: string;
contextId?: string;
} {
const toolApprovals: ToolApprovalInfo[] = [];
const agentResponses: AgentResponseInfo[] = [];
const thoughts: Array<{ subject: string; description: string }> = [];
let state: string | undefined;
let taskId: string | undefined;
let contextId: string | undefined;
if (event.kind === 'status-update') {
state = event.status?.state;
taskId = event.taskId;
contextId = event.contextId;
const parts: Part[] = event.status?.message?.parts ?? [];
// Extract plain text FIRST (incremental chunks) so A2UI accumulated
// text is added AFTER — backward iteration will prefer A2UI.
const plainText = extractTextFromParts(parts);
if (plainText) {
agentResponses.push({ text: plainText, status: '' });
}
const a2uiGroups = extractA2UIParts(parts);
for (const messages of a2uiGroups) {
parseA2UIMessages(messages, toolApprovals, agentResponses, thoughts);
}
} else if (event.kind === 'task') {
state = event.status?.state;
taskId = event.id;
contextId = event.contextId;
const parts = extractAllParts(event);
const plainText = extractTextFromParts(parts);
if (plainText) {
agentResponses.push({ text: plainText, status: '' });
}
const a2uiGroups = extractA2UIParts(parts);
for (const messages of a2uiGroups) {
parseA2UIMessages(messages, toolApprovals, agentResponses, thoughts);
}
} else if (event.kind === 'message') {
contextId = event.contextId;
taskId = event.taskId;
const parts: Part[] = event.parts ?? [];
const plainText = extractTextFromParts(parts);
if (plainText) {
agentResponses.push({ text: plainText, status: '' });
}
const a2uiGroups = extractA2UIParts(parts);
for (const messages of a2uiGroups) {
parseA2UIMessages(messages, toolApprovals, agentResponses, thoughts);
}
}
// Build text from the last non-empty agent response
let text = '';
for (let i = agentResponses.length - 1; i >= 0; i--) {
if (agentResponses[i].text) {
text = agentResponses[i].text;
break;
}
}
// Add thought summaries
if (thoughts.length > 0) {
const thoughtText = thoughts
.map((t) => `_${t.subject}_: ${t.description}`)
.join('\n');
text = text ? `${thoughtText}\n\n${text}` : thoughtText;
}
return {
text,
toolApprovals: deduplicateToolApprovals(toolApprovals),
state,
taskId,
contextId,
};
}
@@ -0,0 +1,365 @@
/**
* @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.
// Include thread info so replies appear in the same thread as the user's
// message. Without it, createMessageAction creates a top-level message.
const message: Record<string, unknown> = {};
if (response.text) {
message['text'] = response.text;
}
if (response.cardsV2) {
message['cardsV2'] = response.cardsV2;
}
if (response.thread) {
message['thread'] = response.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) {
// If the handler returned an empty response (messages pushed via
// Chat API), return a bare {} so Add-ons doesn't try to create
// an empty message — which causes Google Chat to retry the webhook.
if (!response.text && !response.cardsV2 && !response.actionResponse) {
logger.info(`[ChatBridge] Add-ons response: {} (empty ack)`);
res.json({});
} else {
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,62 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Standalone Google Chat bridge server.
* Runs independently from the A2A agent server connects to it
* via the A2A protocol over HTTP. Deploy as a separate Cloud Run
* service for independent scaling.
*/
import express from 'express';
import { createChatBridgeRoutes } from './routes.js';
import { logger } from '../utils/logger.js';
function main() {
const a2aServerUrl = process.env['A2A_SERVER_URL'];
if (!a2aServerUrl) {
logger.error(
'[ChatBridge] A2A_SERVER_URL is required. Set it to the A2A agent server URL.',
);
process.exit(1);
}
const app = express();
app.use(express.json());
const chatRoutes = createChatBridgeRoutes({
a2aServerUrl,
projectNumber: process.env['CHAT_PROJECT_NUMBER'],
debug: process.env['CHAT_BRIDGE_DEBUG'] === 'true',
gcsBucket: process.env['GCS_BUCKET_NAME'],
serviceAccountKeyPath: process.env['CHAT_SA_KEY_PATH'],
});
app.use(chatRoutes);
// Root health check
app.get('/', (_req, res) => {
res.json({
service: 'gemini-chat-bridge',
status: 'ok',
a2aServerUrl,
});
});
const port = Number(process.env['PORT'] || 8080);
const host = process.env['HOST'] || '0.0.0.0';
app.listen(port, host, () => {
logger.info(`[ChatBridge] Server started on http://${host}:${port}`);
logger.info(`[ChatBridge] Connected to A2A agent at ${a2aServerUrl}`);
});
}
process.on('uncaughtException', (error) => {
logger.error('[ChatBridge] Unhandled exception:', error);
process.exit(1);
});
main();
@@ -0,0 +1,240 @@
/**
* @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;
/** When true, an async task is currently processing. */
asyncProcessing?: boolean;
/** When true, session has been cancelled (e.g. by /reset). Signals async processing to stop. */
cancelled?: 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 {
const session = this.sessions.get(threadName);
if (session) {
// Signal any in-flight async processing to stop
session.cancelled = true;
}
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,143 @@
/**
* @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;
/** Path to service account key for Chat API auth (optional, uses ADC if not set) */
serviceAccountKeyPath?: string;
}
+1
View File
@@ -85,6 +85,7 @@ export class InitCommand implements Command {
if (!context.agentExecutor) {
throw new Error('Agent executor not found in context.');
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentExecutor = context.agentExecutor as CoderAgentExecutor;
const agentSettings: AgentSettings = {
@@ -41,9 +41,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
return mockConfig;
}),
loadServerHierarchicalMemory: vi
.fn()
.mockResolvedValue({ memoryContent: '', fileCount: 0, filePaths: [] }),
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
memoryContent: { global: '', extension: '', project: '' },
fileCount: 0,
filePaths: [],
}),
startupProfiler: {
flush: vi.fn(),
},
+2 -1
View File
@@ -59,7 +59,7 @@ export async function loadConfig(
const configParams: ConfigParameters = {
sessionId: taskId,
model: PREVIEW_GEMINI_MODEL,
model: process.env['GEMINI_MODEL'] || PREVIEW_GEMINI_MODEL,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: undefined, // Sandbox might not be relevant for a server-side agent
targetDir: workspaceDir, // Or a specific directory the agent operates on
@@ -77,6 +77,7 @@ export async function loadConfig(
cwd: workspaceDir,
telemetry: {
enabled: settings.telemetry?.enabled,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
target: settings.telemetry?.target as TelemetryTarget,
otlpEndpoint:
process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ??
@@ -93,6 +93,7 @@ function loadExtension(extensionDir: string): GeminiCLIExtension | null {
try {
const configContent = fs.readFileSync(configFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const config = JSON.parse(configContent) as ExtensionConfig;
if (!config.name || !config.version) {
logger.error(
@@ -107,6 +108,7 @@ function loadExtension(extensionDir: string): GeminiCLIExtension | null {
.map((contextFileName) => path.join(extensionDir, contextFileName))
.filter((contextFilePath) => fs.existsSync(contextFilePath));
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return {
name: config.name,
version: config.version,
@@ -140,6 +142,7 @@ export function loadInstallMetadata(
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
try {
const configContent = fs.readFileSync(metadataFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
return metadata;
} catch (e) {
@@ -67,6 +67,7 @@ export function loadSettings(workspaceDir: string): Settings {
try {
if (fs.existsSync(USER_SETTINGS_PATH)) {
const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parsedUserSettings = JSON.parse(
stripJsonComments(userContent),
) as Settings;
@@ -89,6 +90,7 @@ export function loadSettings(workspaceDir: string): Settings {
try {
if (fs.existsSync(workspaceSettingsPath)) {
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parsedWorkspaceSettings = JSON.parse(
stripJsonComments(projectContent),
) as Settings;
@@ -139,10 +141,12 @@ function resolveEnvVarsInObject<T>(obj: T): T {
}
if (typeof obj === 'string') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return resolveEnvVarsInString(obj) as unknown as T;
}
if (Array.isArray(obj)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T;
}
+38 -4
View File
@@ -28,6 +28,7 @@ import { commandRegistry } from '../commands/command-registry.js';
import { debugLogger, SimpleExtensionLoader } from '@google/gemini-cli-core';
import type { Command, CommandArgument } from '../commands/types.js';
import { GitService } from '@google/gemini-cli-core';
import { getA2UIAgentExtension } from '../a2ui/a2ui-extension.js';
type CommandResponse = {
name: string;
@@ -46,11 +47,12 @@ const coderAgentCard: AgentCard = {
url: 'https://google.com',
},
protocolVersion: '0.3.0',
version: '0.0.2', // Incremented version
version: '0.1.0', // A2UI-enabled version
capabilities: {
streaming: true,
pushNotifications: false,
pushNotifications: true,
stateTransitionHistory: true,
extensions: [getA2UIAgentExtension()],
},
securitySchemes: undefined,
security: undefined,
@@ -75,7 +77,11 @@ const coderAgentCard: AgentCard = {
};
export function updateCoderAgentCardUrl(port: number) {
coderAgentCard.url = `http://localhost:${port}/`;
// On Cloud Run, use the public service URL so remote clients can reach us
const publicUrl = process.env['CODER_AGENT_PUBLIC_URL'];
coderAgentCard.url = publicUrl
? publicUrl.replace(/\/$/, '') + '/'
: `http://localhost:${port}/`;
}
async function handleExecuteCommand(
@@ -118,6 +124,7 @@ async function handleExecuteCommand(
const eventHandler = (event: AgentExecutionEvent) => {
const jsonRpcResponse = {
jsonrpc: '2.0',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
id: 'taskId' in event ? event.taskId : (event as Message).messageId,
result: event,
};
@@ -199,6 +206,31 @@ export async function createApp() {
requestStorage.run({ req }, next);
});
// SSE keepalive — sends periodic comment lines to prevent Cloud Run's
// load balancer from closing idle SSE connections during long tool
// executions (npm install, tsc builds, etc.). SSE comments (`: ...`)
// are ignored by conformant parsers per the spec.
expressApp.use((req, res, next) => {
const origFlush = res.flushHeaders;
res.flushHeaders = function (this: express.Response) {
origFlush.call(this);
const ct = this.getHeader('content-type');
if (ct && String(ct).includes('text/event-stream')) {
const timer = setInterval(() => {
if (!res.writableEnded) {
res.write(': keepalive\n\n');
} else {
clearInterval(timer);
}
}, 15_000);
res.on('close', () => clearInterval(timer));
}
};
next();
});
// Google Chat bridge runs as a separate service (src/chat-bridge/server.ts).
// It connects to this A2A server over HTTP.
const appBuilder = new A2AExpressApp(requestHandler);
expressApp = appBuilder.setupRoutes(expressApp, '');
expressApp.use(express.json());
@@ -206,6 +238,7 @@ export async function createApp() {
expressApp.post('/tasks', async (req, res) => {
try {
const taskId = uuidv4();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentSettings = req.body.agentSettings as
| AgentSettings
| undefined;
@@ -328,7 +361,8 @@ export async function main() {
const expressApp = await createApp();
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();
let actualPort;
if (process.env['CODER_AGENT_PORT']) {
+61 -2
View File
@@ -18,7 +18,7 @@ import { setTargetDir } from '../config/config.js';
import { getPersistedState, type PersistedTaskMetadata } from '../types.js';
import { v4 as uuidv4 } from 'uuid';
type ObjectType = 'metadata' | 'workspace';
type ObjectType = 'metadata' | 'workspace' | 'conversation';
const getTmpArchiveFilename = (taskId: string): string =>
`task-${taskId}-workspace-${uuidv4()}.tar.gz`;
@@ -95,6 +95,7 @@ export class GCSTaskStore implements TaskStore {
await this.ensureBucketInitialized();
const taskId = task.id;
const persistedState = getPersistedState(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
task.metadata as PersistedTaskMetadata,
);
@@ -223,6 +224,28 @@ export class GCSTaskStore implements TaskStore {
`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) {
logger.error(`Failed to save task ${taskId} to GCS:`, error);
throw error;
@@ -279,6 +302,29 @@ export class GCSTaskStore implements TaskStore {
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 {
id: taskId,
contextId: loadedMetadata._contextId || uuidv4(),
@@ -299,6 +345,8 @@ export class GCSTaskStore implements TaskStore {
}
export class NoOpTaskStore implements TaskStore {
private cache = new Map<string, SDKTask>();
constructor(private realStore: TaskStore) {}
async save(task: SDKTask): Promise<void> {
@@ -307,9 +355,20 @@ export class NoOpTaskStore implements TaskStore {
}
async load(taskId: string): Promise<SDKTask | undefined> {
const cached = this.cache.get(taskId);
if (cached) {
logger.info(
`[NoOpTaskStore] load called for task ${taskId}, returning cached.`,
);
return cached;
}
logger.info(
`[NoOpTaskStore] load called for task ${taskId}, delegating to real store.`,
);
return this.realStore.load(taskId);
const result = await this.realStore.load(taskId);
if (result) {
this.cache.set(taskId, result);
}
return result;
}
}
+1
View File
@@ -125,6 +125,7 @@ export const METADATA_KEY = '__persistedState';
export function getPersistedState(
metadata: PersistedTaskMetadata,
): PersistedStateMetadata | undefined {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return metadata?.[METADATA_KEY] as PersistedStateMetadata | undefined;
}
+13 -3
View File
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import type {
Task as SDKTask,
TaskStatusUpdateEvent,
@@ -16,6 +17,7 @@ import {
GeminiClient,
HookSystem,
PolicyDecision,
tmpdir,
} from '@google/gemini-cli-core';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
import type { Config, Storage } from '@google/gemini-cli-core';
@@ -24,6 +26,8 @@ import { expect, vi } from 'vitest';
export function createMockConfig(
overrides: Partial<Config> = {},
): Partial<Config> {
const tmpDir = tmpdir();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const mockConfig = {
getToolRegistry: vi.fn().mockReturnValue({
getTool: vi.fn(),
@@ -38,11 +42,12 @@ export function createMockConfig(
getWorkspaceContext: vi.fn().mockReturnValue({
isPathWithinWorkspace: () => true,
}),
getTargetDir: () => '/test',
getTargetDir: () => tmpDir,
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
storage: {
getProjectTempDir: () => '/tmp',
getProjectTempCheckpointsDir: () => '/tmp/checkpoints',
getProjectTempDir: () => tmpDir,
getProjectTempCheckpointsDir: () => path.join(tmpDir, 'checkpoints'),
} as Storage,
getTruncateToolOutputThreshold: () =>
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
@@ -145,6 +150,7 @@ export function assertUniqueFinalEventIsLast(
events: SendStreamingMessageSuccessResponse[],
) {
// Final event is input-required & final
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const finalEvent = events[events.length - 1].result as TaskStatusUpdateEvent;
expect(finalEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'state-change',
@@ -154,9 +160,11 @@ export function assertUniqueFinalEventIsLast(
// There is only one event with final and its the last
expect(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
events.filter((e) => (e.result as TaskStatusUpdateEvent).final).length,
).toBe(1);
expect(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
events.findIndex((e) => (e.result as TaskStatusUpdateEvent).final),
).toBe(events.length - 1);
}
@@ -165,11 +173,13 @@ export function assertTaskCreationAndWorkingStatus(
events: SendStreamingMessageSuccessResponse[],
) {
// Initial task creation event
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const taskEvent = events[0].result as SDKTask;
expect(taskEvent.kind).toBe('task');
expect(taskEvent.status.state).toBe('submitted');
// Status update: working
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const workingEvent = events[1].result as TaskStatusUpdateEvent;
expect(workingEvent.kind).toBe('status-update');
expect(workingEvent.status.state).toBe('working');
+48
View File
@@ -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."
+1
View File
@@ -54,6 +54,7 @@
"mnemonist": "^0.40.3",
"open": "^10.1.2",
"prompts": "^2.4.2",
"proper-lockfile": "^4.1.2",
"react": "^19.2.0",
"read-package-up": "^11.0.0",
"shell-quote": "^1.8.3",
@@ -71,6 +71,7 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
extensionManager,
name,
setting,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope as ExtensionSettingScope,
);
}
@@ -79,6 +80,7 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
await configureExtension(
extensionManager,
name,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope as ExtensionSettingScope,
);
}
@@ -86,6 +88,7 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
else {
await configureAllExtensions(
extensionManager,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope as ExtensionSettingScope,
);
}
@@ -79,7 +79,9 @@ export const disableCommand: CommandModule = {
}),
handler: async (argv) => {
await handleDisable({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as string,
});
await exitCli();
@@ -105,7 +105,9 @@ export const enableCommand: CommandModule = {
}),
handler: async (argv) => {
await handleEnable({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as string,
});
await exitCli();
@@ -99,10 +99,15 @@ export const installCommand: CommandModule = {
}),
handler: async (argv) => {
await handleInstall({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
source: argv['source'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
ref: argv['ref'] as string | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
autoUpdate: argv['auto-update'] as boolean | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
allowPreRelease: argv['pre-release'] as boolean | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
consent: argv['consent'] as boolean | undefined,
});
await exitCli();
@@ -79,7 +79,9 @@ export const linkCommand: CommandModule = {
.check((_) => true),
handler: async (argv) => {
await handleLink({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
path: argv['path'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
consent: argv['consent'] as boolean | undefined,
});
await exitCli();
@@ -62,6 +62,7 @@ export const listCommand: CommandModule = {
}),
handler: async (argv) => {
await handleList({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
outputFormat: argv['output-format'] as 'text' | 'json',
});
await exitCli();
@@ -98,7 +98,9 @@ export const newCommand: CommandModule = {
},
handler: async (args) => {
await handleNew({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
path: args['path'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
template: args['template'] as string | undefined,
});
await exitCli();
@@ -71,6 +71,7 @@ export const uninstallCommand: CommandModule = {
}),
handler: async (argv) => {
await handleUninstall({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
names: argv['names'] as string[],
});
await exitCli();
@@ -155,7 +155,9 @@ export const updateCommand: CommandModule = {
}),
handler: async (argv) => {
await handleUpdate({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
all: argv['all'] as boolean | undefined,
});
await exitCli();
@@ -100,6 +100,7 @@ export const validateCommand: CommandModule = {
}),
handler: async (args) => {
await handleValidate({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
path: args['path'] as string,
});
await exitCli();
@@ -70,6 +70,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown {
return claudeHook;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const hook = claudeHook as Record<string, unknown>;
const migrated: Record<string, unknown> = {};
@@ -107,10 +108,12 @@ function migrateClaudeHooks(claudeConfig: unknown): Record<string, unknown> {
return {};
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const config = claudeConfig as Record<string, unknown>;
const geminiHooks: Record<string, unknown> = {};
// Check if there's a hooks section
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const hooksSection = config['hooks'] as Record<string, unknown> | undefined;
if (!hooksSection || typeof hooksSection !== 'object') {
return {};
@@ -130,6 +133,7 @@ function migrateClaudeHooks(claudeConfig: unknown): Record<string, unknown> {
return def;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const definition = def as Record<string, unknown>;
const migratedDef: Record<string, unknown> = {};
@@ -179,6 +183,7 @@ export async function handleMigrateFromClaude() {
sourceFile = claudeLocalSettingsPath;
try {
const content = fs.readFileSync(claudeLocalSettingsPath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
claudeSettings = JSON.parse(stripJsonComments(content)) as Record<
string,
unknown
@@ -192,6 +197,7 @@ export async function handleMigrateFromClaude() {
sourceFile = claudeSettingsPath;
try {
const content = fs.readFileSync(claudeSettingsPath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
claudeSettings = JSON.parse(stripJsonComments(content)) as Record<
string,
unknown
@@ -259,6 +265,7 @@ export const migrateCommand: CommandModule = {
default: false,
}),
handler: async (argv) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const args = argv as unknown as MigrateArgs;
if (args.fromClaude) {
await handleMigrateFromClaude();
+14
View File
@@ -219,24 +219,38 @@ export const addCommand: CommandModule = {
.middleware((argv) => {
// Handle -- separator args as server args if present
if (argv['--']) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const existingArgs = (argv['args'] as Array<string | number>) || [];
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv['args'] = [...existingArgs, ...(argv['--'] as string[])];
}
}),
handler: async (argv) => {
await addMcpServer(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv['name'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv['commandOrUrl'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv['args'] as Array<string | number>,
{
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
transport: argv['transport'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
env: argv['env'] as string[],
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
header: argv['header'] as string[],
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
timeout: argv['timeout'] as number | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
trust: argv['trust'] as boolean | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
description: argv['description'] as string | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
includeTools: argv['includeTools'] as string[] | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
excludeTools: argv['excludeTools'] as string[] | undefined,
},
);
+2
View File
@@ -55,7 +55,9 @@ export const removeCommand: CommandModule = {
choices: ['user', 'project'],
}),
handler: async (argv) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
await removeMcpServer(argv['name'] as string, {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as string,
});
await exitCli();
@@ -53,6 +53,7 @@ export const disableCommand: CommandModule = {
? SettingScope.Workspace
: SettingScope.User;
await handleDisable({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string,
scope,
});
@@ -40,6 +40,7 @@ export const enableCommand: CommandModule = {
}),
handler: async (argv) => {
await handleEnable({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string,
});
await exitCli();
@@ -102,9 +102,13 @@ export const installCommand: CommandModule = {
}),
handler: async (argv) => {
await handleInstall({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
source: argv['source'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as 'user' | 'workspace',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
path: argv['path'] as string | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
consent: argv['consent'] as boolean | undefined,
});
await exitCli();
+3
View File
@@ -84,8 +84,11 @@ export const linkCommand: CommandModule = {
}),
handler: async (argv) => {
await handleLink({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
path: argv['path'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as 'user' | 'workspace',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
consent: argv['consent'] as boolean | undefined,
});
await exitCli();
+2
View File
@@ -18,6 +18,7 @@ export async function handleList(args: { all?: boolean }) {
const config = await loadCliConfig(
settings.merged,
'skills-list-session',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
{
debug: false,
} as Partial<CliArgs> as CliArgs,
@@ -72,6 +73,7 @@ export const listCommand: CommandModule = {
default: false,
}),
handler: async (argv) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
await handleList({ all: argv['all'] as boolean });
await exitCli();
},
@@ -64,7 +64,9 @@ export const uninstallCommand: CommandModule = {
}),
handler: async (argv) => {
await handleUninstall({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as 'user' | 'workspace',
});
await exitCli();
+88 -8
View File
@@ -141,6 +141,22 @@ vi.mock('@google/gemini-cli-core', async () => {
defaultDecision: ServerConfig.PolicyDecision.ASK_USER,
approvalMode: ServerConfig.ApprovalMode.DEFAULT,
})),
isHeadlessMode: vi.fn((opts) => {
if (process.env['VITEST'] === 'true') {
return (
!!opts?.prompt ||
(!!process.stdin && !process.stdin.isTTY) ||
(!!process.stdout && !process.stdout.isTTY)
);
}
return (
!!opts?.prompt ||
process.env['CI'] === 'true' ||
process.env['GITHUB_ACTIONS'] === 'true' ||
(!!process.stdin && !process.stdin.isTTY) ||
(!!process.stdout && !process.stdout.isTTY)
);
}),
};
});
@@ -154,6 +170,8 @@ vi.mock('./extension-manager.js', () => {
// Global setup to ensure clean environment for all tests in this file
const originalArgv = process.argv;
const originalGeminiModel = process.env['GEMINI_MODEL'];
const originalStdoutIsTTY = process.stdout.isTTY;
const originalStdinIsTTY = process.stdin.isTTY;
beforeEach(() => {
delete process.env['GEMINI_MODEL'];
@@ -162,6 +180,18 @@ beforeEach(() => {
ExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue(undefined);
// Default to interactive mode for tests unless otherwise specified
Object.defineProperty(process.stdout, 'isTTY', {
value: true,
configurable: true,
writable: true,
});
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
writable: true,
});
});
afterEach(() => {
@@ -171,6 +201,16 @@ afterEach(() => {
} else {
delete process.env['GEMINI_MODEL'];
}
Object.defineProperty(process.stdout, 'isTTY', {
value: originalStdoutIsTTY,
configurable: true,
writable: true,
});
Object.defineProperty(process.stdin, 'isTTY', {
value: originalStdinIsTTY,
configurable: true,
writable: true,
});
});
describe('parseArguments', () => {
@@ -249,6 +289,16 @@ describe('parseArguments', () => {
});
describe('positional arguments and @commands', () => {
beforeEach(() => {
// Default to headless mode for these tests as they mostly expect one-shot behavior
process.stdin.isTTY = false;
Object.defineProperty(process.stdout, 'isTTY', {
value: false,
configurable: true,
writable: true,
});
});
it.each([
{
description:
@@ -379,8 +429,12 @@ describe('parseArguments', () => {
);
it('should include a startup message when converting positional query to interactive prompt', async () => {
const originalIsTTY = process.stdin.isTTY;
process.stdin.isTTY = true;
Object.defineProperty(process.stdout, 'isTTY', {
value: true,
configurable: true,
writable: true,
});
process.argv = ['node', 'script.js', 'hello'];
try {
@@ -389,7 +443,7 @@ describe('parseArguments', () => {
'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.',
);
} finally {
process.stdin.isTTY = originalIsTTY;
// beforeEach handles resetting
}
});
});
@@ -1732,14 +1786,29 @@ describe('loadCliConfig model selection', () => {
});
describe('loadCliConfig folderTrust', () => {
let originalVitest: string | undefined;
let originalIntegrationTest: string | undefined;
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
originalVitest = process.env['VITEST'];
originalIntegrationTest = process.env['GEMINI_CLI_INTEGRATION_TEST'];
delete process.env['VITEST'];
delete process.env['GEMINI_CLI_INTEGRATION_TEST'];
});
afterEach(() => {
if (originalVitest !== undefined) {
process.env['VITEST'] = originalVitest;
}
if (originalIntegrationTest !== undefined) {
process.env['GEMINI_CLI_INTEGRATION_TEST'] = originalIntegrationTest;
}
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
@@ -1798,10 +1867,11 @@ describe('loadCliConfig with includeDirectories', () => {
vi.restoreAllMocks();
});
it('should combine and resolve paths from settings and CLI arguments', async () => {
it.skip('should combine and resolve paths from settings and CLI arguments', async () => {
const mockCwd = path.resolve(path.sep, 'home', 'user', 'project');
process.argv = [
'node',
'script.js',
'--include-directories',
`${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`,
@@ -2555,7 +2625,7 @@ describe('loadCliConfig approval mode', () => {
it('should use approvalMode from settings when no CLI flags are set', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'auto_edit' },
general: { defaultApprovalMode: 'auto_edit' },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
@@ -2567,7 +2637,7 @@ describe('loadCliConfig approval mode', () => {
it('should prioritize --approval-mode flag over settings', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'default' },
general: { defaultApprovalMode: 'default' },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
@@ -2579,7 +2649,7 @@ describe('loadCliConfig approval mode', () => {
it('should prioritize --yolo flag over settings', async () => {
process.argv = ['node', 'script.js', '--yolo'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'auto_edit' },
general: { defaultApprovalMode: 'auto_edit' },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
@@ -2589,7 +2659,7 @@ describe('loadCliConfig approval mode', () => {
it('should respect plan mode from settings when experimental.plan is enabled', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'plan' },
general: { defaultApprovalMode: 'plan' },
experimental: { plan: true },
});
const argv = await parseArguments(settings);
@@ -2600,7 +2670,7 @@ describe('loadCliConfig approval mode', () => {
it('should throw error if plan mode is in settings but experimental.plan is disabled', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'plan' },
general: { defaultApprovalMode: 'plan' },
experimental: { plan: false },
});
const argv = await parseArguments(settings);
@@ -2779,6 +2849,16 @@ describe('Output format', () => {
describe('parseArguments with positional prompt', () => {
const originalArgv = process.argv;
beforeEach(() => {
// Default to headless mode for these tests as they mostly expect one-shot behavior
process.stdin.isTTY = false;
Object.defineProperty(process.stdout, 'isTTY', {
value: false,
configurable: true,
writable: true,
});
});
afterEach(() => {
process.argv = originalArgv;
});
+24 -11
View File
@@ -32,17 +32,17 @@ import {
ASK_USER_TOOL_NAME,
getVersion,
PREVIEW_GEMINI_MODEL_AUTO,
type HierarchicalMemory,
coreEvents,
GEMINI_MODEL_ALIAS_AUTO,
getAdminErrorMessage,
isHeadlessMode,
Config,
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
} from '@google/gemini-cli-core';
import type {
HookDefinition,
HookEventName,
OutputFormat,
type HookDefinition,
type HookEventName,
type OutputFormat,
} from '@google/gemini-cli-core';
import {
type Settings,
@@ -280,6 +280,7 @@ export async function parseArguments(
.check((argv) => {
// The 'query' positional can be a string (for one arg) or string[] (for multiple).
// This guard safely checks if any positional argument was provided.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const query = argv['query'] as string | string[] | undefined;
const hasPositionalQuery = Array.isArray(query)
? query.length > 0
@@ -297,6 +298,7 @@ export async function parseArguments(
if (
argv['outputFormat'] &&
!['text', 'json', 'stream-json'].includes(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv['outputFormat'] as string,
)
) {
@@ -345,6 +347,7 @@ export async function parseArguments(
}
// Normalize query args: handle both quoted "@path file" and unquoted @path file
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const queryArg = (result as { query?: string | string[] | undefined }).query;
const q: string | undefined = Array.isArray(queryArg)
? queryArg.join(' ')
@@ -352,7 +355,7 @@ export async function parseArguments(
// -p/--prompt forces non-interactive mode; positional args default to interactive in TTY
if (q && !result['prompt']) {
if (process.stdin.isTTY) {
if (!isHeadlessMode()) {
startupMessages.push(
'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.',
);
@@ -368,6 +371,7 @@ export async function parseArguments(
// The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return result as unknown as CliArgs;
}
@@ -436,7 +440,11 @@ export async function loadCliConfig(
const ideMode = settings.ide?.enabled ?? false;
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
const folderTrust =
process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true' ||
process.env['VITEST'] === 'true'
? false
: (settings.security?.folderTrust?.enabled ?? false);
const trustedFolder = isWorkspaceTrusted(settings, cwd)?.isTrusted ?? false;
// Set the context filename in the server's memoryTool module BEFORE loading memory
@@ -472,6 +480,7 @@ export async function loadCliConfig(
requestSetting: promptForSetting,
workspaceDir: cwd,
enabledExtensionOverrides: argv.extensions,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
clientVersion: await getVersion(),
});
@@ -479,7 +488,7 @@ export async function loadCliConfig(
const experimentalJitContext = settings.experimental?.jitContext ?? false;
let memoryContent = '';
let memoryContent: string | HierarchicalMemory = '';
let fileCount = 0;
let filePaths: string[] = [];
@@ -510,8 +519,8 @@ export async function loadCliConfig(
const rawApprovalMode =
argv.approvalMode ||
(argv.yolo ? 'yolo' : undefined) ||
((settings.tools?.approvalMode as string) !== 'yolo'
? settings.tools.approvalMode
((settings.general?.defaultApprovalMode as string) !== 'yolo'
? settings.general?.defaultApprovalMode
: undefined);
if (rawApprovalMode) {
@@ -575,6 +584,7 @@ export async function loadCliConfig(
let telemetrySettings;
try {
telemetrySettings = await resolveTelemetrySettings({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
env: process.env as unknown as Record<string, string | undefined>,
settings: settings.telemetry,
});
@@ -592,7 +602,9 @@ export async function loadCliConfig(
const interactive =
!!argv.promptInteractive ||
!!argv.experimentalAcp ||
(process.stdin.isTTY && !argv.query && !argv.prompt && !argv.isCommand);
(!isHeadlessMode({ prompt: argv.prompt }) &&
!argv.query &&
!argv.isCommand);
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
const allowedToolsSet = new Set(allowedTools);
@@ -802,6 +814,7 @@ export async function loadCliConfig(
eventEmitter: coreEvents,
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
output: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
},
fakeResponses: argv.fakeResponses,
@@ -16,10 +16,11 @@ import {
vi,
afterEach,
} from 'vitest';
import { createExtension } from '../test-utils/createExtension.js';
import { ExtensionManager } from './extension-manager.js';
import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
import { GEMINI_DIR, type Config } from '@google/gemini-cli-core';
import { GEMINI_DIR, type Config, tmpdir } from '@google/gemini-cli-core';
import { createTestMergedSettings, SettingScope } from './settings.js';
describe('ExtensionManager theme loading', () => {
@@ -29,7 +30,7 @@ describe('ExtensionManager theme loading', () => {
beforeAll(async () => {
tempHomeDir = await fs.promises.mkdtemp(
path.join(fs.realpathSync('/tmp'), 'gemini-cli-test-'),
path.join(tmpdir(), 'gemini-cli-test-'),
);
});
@@ -85,6 +86,7 @@ describe('ExtensionManager theme loading', () => {
await extensionManager.loadExtensions();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const mockConfig = {
getEnableExtensionReloading: () => false,
getMcpClientManager: () => ({
@@ -170,6 +172,7 @@ describe('ExtensionManager theme loading', () => {
await extensionManager.loadExtensions();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const mockConfig = {
getWorkingDir: () => tempHomeDir,
shouldLoadMemoryFromIncludeDirectories: () => false,
+10 -1
View File
@@ -188,7 +188,10 @@ export class ExtensionManager extends ExtensionLoader {
)
) {
const trustedFolders = loadTrustedFolders();
trustedFolders.setValue(this.workspaceDir, TrustLevel.TRUST_FOLDER);
await trustedFolders.setValue(
this.workspaceDir,
TrustLevel.TRUST_FOLDER,
);
} else {
throw new Error(
`Could not install extension because the current workspace at ${this.workspaceDir} is not trusted.`,
@@ -727,6 +730,7 @@ Would you like to attempt to install via "git clone" instead?`,
if (Object.keys(hookEnv).length > 0) {
for (const eventName of Object.keys(hooks)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const eventHooks = hooks[eventName as HookEventName];
if (eventHooks) {
for (const definition of eventHooks) {
@@ -823,13 +827,16 @@ Would you like to attempt to install via "git clone" instead?`,
}
try {
const configContent = await fs.promises.readFile(configFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const rawConfig = JSON.parse(configContent) as ExtensionConfig;
if (!rawConfig.name || !rawConfig.version) {
throw new Error(
`Invalid configuration in ${configFilePath}: missing ${!rawConfig.name ? '"name"' : '"version"'}`,
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const config = recursivelyHydrateStrings(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
rawConfig as unknown as JsonObject,
{
extensionPath: extensionDir,
@@ -875,6 +882,7 @@ Would you like to attempt to install via "git clone" instead?`,
// Hydrate variables in the hooks configuration
const hydratedHooks = recursivelyHydrateStrings(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
rawHooks.hooks as unknown as JsonObject,
{
...context,
@@ -885,6 +893,7 @@ Would you like to attempt to install via "git clone" instead?`,
return hydratedHooks;
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
return undefined; // File not found is not an error here.
}
+1
View File
@@ -47,6 +47,7 @@ export function loadInstallMetadata(
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
try {
const configContent = fs.readFileSync(metadataFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
return metadata;
} catch (_e) {
@@ -105,6 +105,7 @@ export class ExtensionRegistryClient {
throw new Error(`Failed to fetch extensions: ${response.statusText}`);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (await response.json()) as RegistryExtension[];
} catch (error) {
// Clear the promise on failure so that subsequent calls can try again
@@ -5,23 +5,20 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as path from 'node:path';
import * as os from 'node:os';
import * as fs from 'node:fs';
import { getMissingSettings } from './extensionSettings.js';
import type { ExtensionConfig } from '../extension.js';
import { ExtensionStorage } from './storage.js';
import {
KeychainTokenStorage,
debugLogger,
type ExtensionInstallMetadata,
type GeminiCLIExtension,
coreEvents,
} from '@google/gemini-cli-core';
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
import { ExtensionManager } from '../extension-manager.js';
import { createTestMergedSettings } from '../settings.js';
// --- Mocks ---
vi.mock('node:fs', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actual = await importOriginal<any>();
@@ -29,11 +26,23 @@ vi.mock('node:fs', async (importOriginal) => {
...actual,
default: {
...actual.default,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)),
existsSync: vi.fn(),
statSync: vi.fn(),
lstatSync: vi.fn(),
realpathSync: vi.fn((p) => p),
},
existsSync: vi.fn(),
statSync: vi.fn(),
lstatSync: vi.fn(),
realpathSync: vi.fn((p) => p),
promises: {
...actual.promises,
mkdir: vi.fn(),
writeFile: vi.fn(),
rm: vi.fn(),
cp: vi.fn(),
readFile: vi.fn(),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)),
};
});
@@ -49,183 +58,101 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
log: vi.fn(),
},
coreEvents: {
emitFeedback: vi.fn(), // Mock emitFeedback
emitFeedback: vi.fn(),
on: vi.fn(),
off: vi.fn(),
emitConsoleLog: vi.fn(),
},
loadSkillsFromDir: vi.fn().mockResolvedValue([]),
loadAgentsFromDirectory: vi
.fn()
.mockResolvedValue({ agents: [], errors: [] }),
logExtensionInstallEvent: vi.fn().mockResolvedValue(undefined),
logExtensionUpdateEvent: vi.fn().mockResolvedValue(undefined),
logExtensionUninstall: vi.fn().mockResolvedValue(undefined),
logExtensionEnable: vi.fn().mockResolvedValue(undefined),
logExtensionDisable: vi.fn().mockResolvedValue(undefined),
Config: vi.fn().mockImplementation(() => ({
getEnableExtensionReloading: vi.fn().mockReturnValue(true),
})),
};
});
// Mock os.homedir because ExtensionStorage uses it
vi.mock('./consent.js', () => ({
maybeRequestConsentOrFail: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./extensionSettings.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('./extensionSettings.js')>();
return {
...actual,
getEnvContents: vi.fn().mockResolvedValue({}),
getMissingSettings: vi.fn(), // We will mock this implementation per test
};
});
vi.mock('../trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn().mockReturnValue({ isTrusted: true }), // Default to trusted to simplify flow
loadTrustedFolders: vi.fn().mockReturnValue({
setValue: vi.fn().mockResolvedValue(undefined),
}),
TrustLevel: { TRUST_FOLDER: 'TRUST_FOLDER' },
}));
// Mock ExtensionStorage to avoid real FS paths
vi.mock('./storage.js', () => ({
ExtensionStorage: class {
constructor(public name: string) {}
getExtensionDir() {
return `/mock/extensions/${this.name}`;
}
static getUserExtensionsDir() {
return '/mock/extensions';
}
static createTmpDir() {
return Promise.resolve('/mock/tmp');
}
},
}));
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
const mockedOs = await importOriginal<typeof import('node:os')>();
return {
...mockedOs,
homedir: vi.fn(),
homedir: vi.fn().mockReturnValue('/mock/home'),
};
});
describe('extensionUpdates', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
let extensionDir: string;
let mockKeychainData: Record<string, Record<string, string>>;
beforeEach(() => {
vi.clearAllMocks();
mockKeychainData = {};
// Default fs mocks
vi.mocked(fs.promises.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.promises.rm).mockResolvedValue(undefined);
vi.mocked(fs.promises.cp).mockResolvedValue(undefined);
// Mock Keychain
vi.mocked(KeychainTokenStorage).mockImplementation(
(serviceName: string) => {
if (!mockKeychainData[serviceName]) {
mockKeychainData[serviceName] = {};
}
const keychainData = mockKeychainData[serviceName];
return {
getSecret: vi
.fn()
.mockImplementation(
async (key: string) => keychainData[key] || null,
),
setSecret: vi
.fn()
.mockImplementation(async (key: string, value: string) => {
keychainData[key] = value;
}),
deleteSecret: vi.fn().mockImplementation(async (key: string) => {
delete keychainData[key];
}),
listSecrets: vi
.fn()
.mockImplementation(async () => Object.keys(keychainData)),
isAvailable: vi.fn().mockResolvedValue(true),
} as unknown as KeychainTokenStorage;
},
);
// Allow directories to exist by default to satisfy Config/WorkspaceContext checks
vi.mocked(fs.existsSync).mockReturnValue(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(fs.lstatSync).mockReturnValue({ isDirectory: () => true } as any);
vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
// Setup Temp Dirs
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext');
// Mock ExtensionStorage to rely on our temp extension dir
vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue(
extensionDir,
);
// Mock getEnvFilePath is checking extensionDir/variables.env? No, it used ExtensionStorage logic.
// getEnvFilePath in extensionSettings.ts:
// if workspace, process.cwd()/.env (we need to mock process.cwd or move tempWorkspaceDir there)
// if user, ExtensionStorage(name).getEnvFilePath() -> joins extensionDir + '.env'
fs.mkdirSync(extensionDir, { recursive: true });
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
tempWorkspaceDir = '/mock/workspace';
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe('getMissingSettings', () => {
it('should return empty list if all settings are present', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's1', description: 'd1', envVar: 'VAR1' },
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
],
};
const extensionId = '12345';
// Setup User Env
const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
fs.writeFileSync(userEnvPath, 'VAR1=val1');
// Setup Keychain
const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext ${extensionId}`,
);
await userKeychain.setSecret('VAR2', 'val2');
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toEqual([]);
});
it('should identify missing non-sensitive settings', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
const extensionId = '12345';
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toHaveLength(1);
expect(missing[0].name).toBe('s1');
});
it('should identify missing sensitive settings', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
],
};
const extensionId = '12345';
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toHaveLength(1);
expect(missing[0].name).toBe('s2');
});
it('should respect settings present in workspace', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
const extensionId = '12345';
// Setup Workspace Env
const workspaceEnvPath = path.join(
tempWorkspaceDir,
EXTENSION_SETTINGS_FILENAME,
);
fs.writeFileSync(workspaceEnvPath, 'VAR1=val1');
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toEqual([]);
});
});
describe('ExtensionManager integration', () => {
it('should warn about missing settings after update', async () => {
// Mock ExtensionManager methods to avoid FS/Network usage
// 1. Setup Data
const newConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.1.0',
@@ -239,31 +166,30 @@ describe('extensionUpdates', () => {
};
const installMetadata: ExtensionInstallMetadata = {
source: extensionDir,
source: '/mock/source',
type: 'local',
autoUpdate: true,
};
// 2. Setup Manager
const manager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
}),
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: null, // Simulate non-interactive
requestSetting: null,
});
// Mock methods called by installOrUpdateExtension
// 3. Mock Internal Manager Methods
vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig);
vi.spyOn(manager, 'getExtensions').mockReturnValue([
{
name: 'test-ext',
version: '1.0.0',
installMetadata,
path: extensionDir,
// Mocks for other required props
path: '/mock/extensions/test-ext',
contextFiles: [],
mcpServers: {},
hooks: undefined,
@@ -275,23 +201,28 @@ describe('extensionUpdates', () => {
} as unknown as GeminiCLIExtension,
]);
vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined);
// Mock loadExtension to return something so the method doesn't crash at the end
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(manager as any, 'loadExtension').mockResolvedValue(
{} as unknown as GeminiCLIExtension,
);
vi.spyOn(manager, 'enableExtension').mockResolvedValue(undefined);
vi.spyOn(manager as any, 'loadExtension').mockResolvedValue({
name: 'test-ext',
version: '1.1.0',
} as GeminiCLIExtension);
// Mock fs.promises for the operations inside installOrUpdateExtension
vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
vi.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
vi.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
vi.mocked(fs.existsSync).mockReturnValue(false); // No hooks
try {
await manager.installOrUpdateExtension(installMetadata, previousConfig);
} catch (_) {
// Ignore errors from copyExtension or others, we just want to verify the warning
}
// 4. Mock External Helpers
// This is the key fix: we explicitly mock `getMissingSettings` to return
// the result we expect, avoiding any real FS or logic execution during the update.
vi.mocked(getMissingSettings).mockResolvedValue([
{
name: 's1',
description: 'd1',
envVar: 'VAR1',
},
]);
// 5. Execute
await manager.installOrUpdateExtension(installMetadata, previousConfig);
// 6. Assert
expect(debugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'Extension "test-ext" has missing settings: s1',
@@ -45,6 +45,7 @@ export async function fetchJson<T>(
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const data = Buffer.concat(chunks).toString();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
resolve(JSON.parse(data) as T);
});
})
@@ -52,9 +52,11 @@ export function recursivelyHydrateStrings<T>(
values: VariableContext,
): T {
if (typeof obj === 'string') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return hydrateString(obj, values) as unknown as T;
}
if (Array.isArray(obj)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return obj.map((item) =>
recursivelyHydrateStrings(item, values),
) as unknown as T;
@@ -64,11 +66,13 @@ export function recursivelyHydrateStrings<T>(
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
newObj[key] = recursivelyHydrateStrings(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(obj as Record<string, unknown>)[key],
values,
);
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return newObj as T;
}
return obj;
+5
View File
@@ -91,6 +91,7 @@ export enum Command {
TOGGLE_YOLO = 'app.toggleYolo',
CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode',
SHOW_MORE_LINES = 'app.showMoreLines',
EXPAND_PASTE = 'app.expandPaste',
FOCUS_SHELL_INPUT = 'app.focusShellInput',
UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput',
CLEAR_SCREEN = 'app.clearScreen',
@@ -289,6 +290,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
{ key: 'o', ctrl: true },
{ key: 's', ctrl: true },
],
[Command.EXPAND_PASTE]: [{ key: 'o', ctrl: true }],
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
@@ -399,6 +401,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.TOGGLE_YOLO,
Command.CYCLE_APPROVAL_MODE,
Command.SHOW_MORE_LINES,
Command.EXPAND_PASTE,
Command.TOGGLE_BACKGROUND_SHELL,
Command.TOGGLE_BACKGROUND_SHELL_LIST,
Command.KILL_BACKGROUND_SHELL,
@@ -499,6 +502,8 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
[Command.SHOW_MORE_LINES]:
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
[Command.EXPAND_PASTE]:
'Expand or collapse a paste placeholder when cursor is over placeholder.',
[Command.BACKGROUND_SHELL_SELECT]:
'Confirm selection in background shell list.',
[Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.',
@@ -358,6 +358,7 @@ export class McpServerEnablementManager {
private async readConfig(): Promise<McpServerEnablementConfig> {
try {
const content = await fs.readFile(this.configFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return JSON.parse(content) as McpServerEnablementConfig;
} catch (error) {
if (
@@ -23,6 +23,7 @@ function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny {
}
if (def.type === 'string') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
if (def.enum) return z.enum(def.enum as [string, ...string[]]);
return z.string();
}
@@ -40,7 +41,7 @@ function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny {
let schema;
if (def.properties) {
const shape: Record<string, z.ZodTypeAny> = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
for (const [key, propDef] of Object.entries(def.properties) as any) {
let propSchema = buildZodSchemaFromJsonSchema(propDef);
if (
@@ -86,9 +87,11 @@ function buildEnumSchema(
}
const values = options.map((opt) => opt.value);
if (values.every((v) => typeof v === 'string')) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return z.enum(values as [string, ...string[]]);
} else if (values.every((v) => typeof v === 'number')) {
return z.union(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
values.map((v) => z.literal(v)) as [
z.ZodLiteral<number>,
z.ZodLiteral<number>,
@@ -97,6 +100,7 @@ function buildEnumSchema(
);
} else {
return z.union(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
values.map((v) => z.literal(v)) as [
z.ZodLiteral<unknown>,
z.ZodLiteral<unknown>,
+34
View File
@@ -1936,6 +1936,40 @@ describe('Settings Loading and Merging', () => {
);
});
it('should migrate tools.approvalMode to general.defaultApprovalMode', () => {
const userSettingsContent = {
tools: {
approvalMode: 'plan',
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const setValueSpy = vi.spyOn(LoadedSettings.prototype, 'setValue');
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
migrateDeprecatedSettings(loadedSettings, true);
expect(setValueSpy).toHaveBeenCalledWith(
SettingScope.User,
'general',
expect.objectContaining({ defaultApprovalMode: 'plan' }),
);
// Verify removal
expect(setValueSpy).toHaveBeenCalledWith(
SettingScope.User,
'tools',
expect.not.objectContaining({ approvalMode: 'plan' }),
);
});
it('should migrate all 4 inverted boolean settings', () => {
const userSettingsContent = {
general: {
+45
View File
@@ -213,6 +213,7 @@ function setNestedProperty(
}
const next = current[key];
if (typeof next === 'object' && next !== null) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
current = next as Record<string, unknown>;
} else {
// This path is invalid, so we stop.
@@ -254,6 +255,7 @@ export function mergeSettings(
// 3. User Settings
// 4. Workspace Settings
// 5. System Settings (as overrides)
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return customDeepMerge(
getMergeStrategyForPath,
schemaDefaults,
@@ -274,6 +276,7 @@ export function mergeSettings(
export function createTestMergedSettings(
overrides: Partial<Settings> = {},
): MergedSettings {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return customDeepMerge(
getMergeStrategyForPath,
getDefaultsFromSchema(),
@@ -355,6 +358,7 @@ export class LoadedSettings {
// The final admin settings are the defaults overridden by remote settings.
// Any admin settings from files are ignored.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
merged.admin = customDeepMerge(
(path: string[]) => getMergeStrategyForPath(['admin', ...path]),
adminDefaults,
@@ -617,6 +621,7 @@ export function loadSettings(
return { settings: {} };
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const settingsObject = rawSettings as Record<string, unknown>;
// Validate settings structure with Zod
@@ -850,6 +855,7 @@ export function migrateDeprecatedSettings(
const uiSettings = settings.ui as Record<string, unknown> | undefined;
if (uiSettings) {
const newUi = { ...uiSettings };
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const accessibilitySettings = newUi['accessibility'] as
| Record<string, unknown>
| undefined;
@@ -880,6 +886,7 @@ export function migrateDeprecatedSettings(
| undefined;
if (contextSettings) {
const newContext = { ...contextSettings };
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const fileFilteringSettings = newContext['fileFiltering'] as
| Record<string, unknown>
| undefined;
@@ -904,6 +911,36 @@ export function migrateDeprecatedSettings(
}
}
// Migrate tools settings
const toolsSettings = settings.tools as Record<string, unknown> | undefined;
if (toolsSettings) {
if (toolsSettings['approvalMode'] !== undefined) {
foundDeprecated.push('tools.approvalMode');
const generalSettings =
(settings.general as Record<string, unknown> | undefined) || {};
const newGeneral = { ...generalSettings };
// Only set defaultApprovalMode if it's not already set
if (newGeneral['defaultApprovalMode'] === undefined) {
newGeneral['defaultApprovalMode'] = toolsSettings['approvalMode'];
loadedSettings.setValue(scope, 'general', newGeneral);
if (!settingsFile.readOnly) {
anyModified = true;
}
}
if (removeDeprecated) {
const newTools = { ...toolsSettings };
delete newTools['approvalMode'];
loadedSettings.setValue(scope, 'tools', newTools);
if (!settingsFile.readOnly) {
anyModified = true;
}
}
}
}
// Migrate experimental agent settings
const experimentalModified = migrateExperimentalSettings(
settings,
@@ -1000,6 +1037,7 @@ function migrateExperimentalSettings(
...(settings.agents as Record<string, unknown> | undefined),
};
const agentsOverrides = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...((agentsSettings['overrides'] as Record<string, unknown>) || {}),
};
let modified = false;
@@ -1011,6 +1049,7 @@ function migrateExperimentalSettings(
const old = experimentalSettings[oldKey];
if (old) {
foundDeprecated?.push(`experimental.${oldKey}`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
migrateFn(old as Record<string, unknown>);
modified = true;
}
@@ -1019,6 +1058,7 @@ function migrateExperimentalSettings(
// Migrate codebaseInvestigatorSettings -> agents.overrides.codebase_investigator
migrateExperimental('codebaseInvestigatorSettings', (old) => {
const override = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(agentsOverrides['codebase_investigator'] as
| Record<string, unknown>
| undefined),
@@ -1027,6 +1067,7 @@ function migrateExperimentalSettings(
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
const runConfig = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(override['runConfig'] as Record<string, unknown> | undefined),
};
if (old['maxNumTurns'] !== undefined)
@@ -1037,16 +1078,19 @@ function migrateExperimentalSettings(
if (old['model'] !== undefined || old['thinkingBudget'] !== undefined) {
const modelConfig = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(override['modelConfig'] as Record<string, unknown> | undefined),
};
if (old['model'] !== undefined) modelConfig['model'] = old['model'];
if (old['thinkingBudget'] !== undefined) {
const generateContentConfig = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(modelConfig['generateContentConfig'] as
| Record<string, unknown>
| undefined),
};
const thinkingConfig = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(generateContentConfig['thinkingConfig'] as
| Record<string, unknown>
| undefined),
@@ -1064,6 +1108,7 @@ function migrateExperimentalSettings(
// Migrate cliHelpAgentSettings -> agents.overrides.cli_help
migrateExperimental('cliHelpAgentSettings', (old) => {
const override = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(agentsOverrides['cli_help'] as Record<string, unknown> | undefined),
};
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
+50 -18
View File
@@ -179,6 +179,33 @@ const SETTINGS_SCHEMA = {
description: 'Enable Vim keybindings',
showInDialog: true,
},
defaultApprovalMode: {
type: 'enum',
label: 'Default Approval Mode',
category: 'General',
requiresRestart: false,
default: 'default',
description: oneLine`
The default approval mode for tool execution.
'default' prompts for approval, 'auto_edit' auto-approves edit tools,
and 'plan' is read-only mode. 'yolo' is not supported yet.
`,
showInDialog: true,
options: [
{ value: 'default', label: 'Default' },
{ value: 'auto_edit', label: 'Auto Edit' },
{ value: 'plan', label: 'Plan' },
],
},
devtools: {
type: 'boolean',
label: 'DevTools',
category: 'General',
requiresRestart: false,
default: false,
description: 'Enable DevTools inspector on launch.',
showInDialog: false,
},
enableAutoUpdate: {
type: 'boolean',
label: 'Enable Auto Update',
@@ -383,6 +410,19 @@ const SETTINGS_SCHEMA = {
description: 'Hide the window title bar',
showInDialog: true,
},
inlineThinkingMode: {
type: 'enum',
label: 'Inline Thinking',
category: 'UI',
requiresRestart: false,
default: 'off',
description: 'Display model thinking inline: off or full.',
showInDialog: true,
options: [
{ value: 'off', label: 'Off' },
{ value: 'full', label: 'Full' },
],
},
showStatusInTitle: {
type: 'boolean',
label: 'Show Thoughts in Title',
@@ -1061,24 +1101,7 @@ const SETTINGS_SCHEMA = {
},
},
},
approvalMode: {
type: 'enum',
label: 'Approval Mode',
category: 'Tools',
requiresRestart: false,
default: 'default',
description: oneLine`
The default approval mode for tool execution.
'default' prompts for approval, 'auto_edit' auto-approves edit tools,
and 'plan' is read-only mode. 'yolo' is not supported yet.
`,
showInDialog: true,
options: [
{ value: 'default', label: 'Default' },
{ value: 'auto_edit', label: 'Auto Edit' },
{ value: 'plan', label: 'Plan' },
],
},
core: {
type: 'array',
label: 'Core Tools',
@@ -1514,6 +1537,15 @@ const SETTINGS_SCHEMA = {
description: 'Enable requesting and fetching of extension settings.',
showInDialog: false,
},
extensionRegistry: {
type: 'boolean',
label: 'Extension Registry Explore UI',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable extension registry explore UI.',
showInDialog: false,
},
extensionReloading: {
type: 'boolean',
label: 'Extension Reloading',
File diff suppressed because it is too large Load Diff
+100 -20
View File
@@ -6,6 +6,8 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import { lock } from 'proper-lockfile';
import {
FatalConfigError,
getErrorMessage,
@@ -13,10 +15,14 @@ import {
ideContextStore,
GEMINI_DIR,
homedir,
isHeadlessMode,
coreEvents,
} from '@google/gemini-cli-core';
import type { Settings } from './settings.js';
import stripJsonComments from 'strip-json-comments';
const { promises: fsPromises } = fs;
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
export function getUserSettingsDir(): string {
@@ -41,6 +47,7 @@ export function isTrustLevel(
): value is TrustLevel {
return (
typeof value === 'string' &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
Object.values(TrustLevel).includes(value as TrustLevel)
);
}
@@ -67,6 +74,13 @@ export interface TrustResult {
const realPathCache = new Map<string, string>();
/**
* Parses the trusted folders JSON content, stripping comments.
*/
function parseTrustedFoldersJson(content: string): unknown {
return JSON.parse(stripJsonComments(content));
}
/**
* FOR TESTING PURPOSES ONLY.
* Clears the real path cache.
@@ -150,19 +164,68 @@ export class LoadedTrustedFolders {
return undefined;
}
setValue(path: string, trustLevel: TrustLevel): void {
const originalTrustLevel = this.user.config[path];
this.user.config[path] = trustLevel;
async setValue(folderPath: string, trustLevel: TrustLevel): Promise<void> {
if (this.errors.length > 0) {
const errorMessages = this.errors.map(
(error) => `Error in ${error.path}: ${error.message}`,
);
throw new FatalConfigError(
`Cannot update trusted folders because the configuration file is invalid:\n${errorMessages.join('\n')}\nPlease fix the file manually before trying to update it.`,
);
}
const dirPath = path.dirname(this.user.path);
if (!fs.existsSync(dirPath)) {
await fsPromises.mkdir(dirPath, { recursive: true });
}
// lockfile requires the file to exist
if (!fs.existsSync(this.user.path)) {
await fsPromises.writeFile(this.user.path, JSON.stringify({}, null, 2), {
mode: 0o600,
});
}
const release = await lock(this.user.path, {
retries: {
retries: 10,
minTimeout: 100,
},
});
try {
saveTrustedFolders(this.user);
} catch (e) {
// Revert the in-memory change if the save failed.
if (originalTrustLevel === undefined) {
delete this.user.config[path];
} else {
this.user.config[path] = originalTrustLevel;
// Re-read the file to handle concurrent updates
const content = await fsPromises.readFile(this.user.path, 'utf-8');
let config: Record<string, TrustLevel>;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
config = parseTrustedFoldersJson(content) as Record<string, TrustLevel>;
} catch (error) {
coreEvents.emitFeedback(
'error',
`Failed to parse trusted folders file at ${this.user.path}. The file may be corrupted.`,
error,
);
config = {};
}
throw e;
const originalTrustLevel = config[folderPath];
config[folderPath] = trustLevel;
this.user.config[folderPath] = trustLevel;
try {
saveTrustedFolders({ ...this.user, config });
} catch (e) {
// Revert the in-memory change if the save failed.
if (originalTrustLevel === undefined) {
delete this.user.config[folderPath];
} else {
this.user.config[folderPath] = originalTrustLevel;
}
throw e;
}
} finally {
await release();
}
}
}
@@ -190,10 +253,8 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
try {
if (fs.existsSync(userPath)) {
const content = fs.readFileSync(userPath, 'utf-8');
const parsed = JSON.parse(stripJsonComments(content)) as Record<
string,
string
>;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parsed = parseTrustedFoldersJson(content) as Record<string, string>;
if (
typeof parsed !== 'object' ||
@@ -241,11 +302,26 @@ export function saveTrustedFolders(
fs.mkdirSync(dirPath, { recursive: true });
}
fs.writeFileSync(
trustedFoldersFile.path,
JSON.stringify(trustedFoldersFile.config, null, 2),
{ encoding: 'utf-8', mode: 0o600 },
);
const content = JSON.stringify(trustedFoldersFile.config, null, 2);
const tempPath = `${trustedFoldersFile.path}.tmp.${crypto.randomUUID()}`;
try {
fs.writeFileSync(tempPath, content, {
encoding: 'utf-8',
mode: 0o600,
});
fs.renameSync(tempPath, trustedFoldersFile.path);
} catch (error) {
// Clean up temp file if it was created but rename failed
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
}
throw error;
}
}
/** Is folder trust feature enabled per the current applied settings */
@@ -282,6 +358,10 @@ export function isWorkspaceTrusted(
workspaceDir: string = process.cwd(),
trustConfig?: Record<string, TrustLevel>,
): TrustResult {
if (isHeadlessMode()) {
return { isTrusted: true, source: undefined };
}
if (!isFolderTrustEnabled(settings)) {
return { isTrusted: true, source: undefined };
}
+2
View File
@@ -86,9 +86,11 @@ export function defer<T = object, U = object>(
...commandModule,
handler: (argv: ArgumentsCamelCase<U>) => {
setDeferredCommand({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
handler: commandModule.handler as (
argv: ArgumentsCamelCase,
) => void | Promise<void>,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv: argv as unknown as ArgumentsCamelCase,
commandName: parentCommandName || 'unknown',
});
+11 -9
View File
@@ -518,11 +518,11 @@ export async function main() {
adminControlsListner.setConfig(config);
if (config.isInteractive() && config.getDebugMode()) {
const { registerActivityLogger } = await import(
'./utils/activityLogger.js'
if (config.isInteractive() && settings.merged.general.devtools) {
const { setupInitialActivityLogger } = await import(
'./utils/devtoolsService.js'
);
registerActivityLogger(config);
await setupInitialActivityLogger(config);
}
// Register config for telemetry shutdown
@@ -603,12 +603,13 @@ export async function main() {
}
// This cleanup isn't strictly needed but may help in certain situations.
process.on('SIGTERM', () => {
const restoreRawMode = () => {
process.stdin.setRawMode(wasRaw);
});
process.on('SIGINT', () => {
process.stdin.setRawMode(wasRaw);
});
};
process.off('SIGTERM', restoreRawMode);
process.on('SIGTERM', restoreRawMode);
process.off('SIGINT', restoreRawMode);
process.on('SIGINT', restoreRawMode);
}
await setupTerminalAndTheme(config, settings);
@@ -819,6 +820,7 @@ function setupAdminControlsListener() {
let config: Config | undefined;
const messageHandler = (msg: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const message = msg as {
type?: string;
settings?: AdminControlsSettings;
+5 -5
View File
@@ -38,9 +38,9 @@ import type { LoadedSettings } from './config/settings.js';
// Mock core modules
vi.mock('./ui/hooks/atCommandProcessor.js');
const mockRegisterActivityLogger = vi.hoisted(() => vi.fn());
vi.mock('./utils/activityLogger.js', () => ({
registerActivityLogger: mockRegisterActivityLogger,
const mockSetupInitialActivityLogger = vi.hoisted(() => vi.fn());
vi.mock('./utils/devtoolsService.js', () => ({
setupInitialActivityLogger: mockSetupInitialActivityLogger,
}));
const mockCoreEvents = vi.hoisted(() => ({
@@ -286,7 +286,7 @@ describe('runNonInteractive', () => {
prompt_id: 'prompt-id-activity-logger',
});
expect(mockRegisterActivityLogger).toHaveBeenCalledWith(mockConfig);
expect(mockSetupInitialActivityLogger).toHaveBeenCalledWith(mockConfig);
vi.unstubAllEnvs();
});
@@ -309,7 +309,7 @@ describe('runNonInteractive', () => {
prompt_id: 'prompt-id-activity-logger-off',
});
expect(mockRegisterActivityLogger).not.toHaveBeenCalled();
expect(mockSetupInitialActivityLogger).not.toHaveBeenCalled();
vi.unstubAllEnvs();
});
+5 -3
View File
@@ -72,10 +72,10 @@ export async function runNonInteractive({
});
if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) {
const { registerActivityLogger } = await import(
'./utils/activityLogger.js'
const { setupInitialActivityLogger } = await import(
'./utils/devtoolsService.js'
);
registerActivityLogger(config);
await setupInitialActivityLogger(config);
}
const { stdout: workingStdout } = createWorkingStdio();
@@ -250,6 +250,7 @@ export async function runNonInteractive({
// Otherwise, slashCommandResult falls through to the default prompt
// handling.
if (slashCommandResult) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
query = slashCommandResult as Part[];
}
}
@@ -271,6 +272,7 @@ export async function runNonInteractive({
error || 'Exiting due to an error processing the @ command.',
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
query = processedQuery as Part[];
}
@@ -125,6 +125,7 @@ export class FileCommandLoader implements ICommandLoader {
} catch (error) {
if (
!signal.aborted &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(error as { code?: string })?.code !== 'ENOENT'
) {
coreEvents.emitFeedback(
+7 -2
View File
@@ -5,6 +5,7 @@
*/
import { act } from 'react';
import { vi } from 'vitest';
// The waitFor from vitest doesn't properly wrap in act(), so we have to
// implement our own like the one in @testing-library/react
@@ -13,7 +14,7 @@ import { act } from 'react';
// for React state updates.
export async function waitFor(
assertion: () => void,
{ timeout = 1000, interval = 50 } = {},
{ timeout = 2000, interval = 50 } = {},
): Promise<void> {
const startTime = Date.now();
@@ -27,7 +28,11 @@ export async function waitFor(
}
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, interval));
if (vi.isFakeTimers()) {
await vi.advanceTimersByTimeAsync(interval);
} else {
await new Promise((resolve) => setTimeout(resolve, interval));
}
});
}
}
@@ -21,7 +21,7 @@ import type { TextBuffer } from '../ui/components/shared/text-buffer.js';
const invalidCharsRegex = /[\b\x1b]/;
function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
const { isNot } = this as any;
let pass = true;
const invalidLines: Array<{ line: number; content: string }> = [];
@@ -50,6 +50,7 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
};
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
expect.extend({
toHaveOnlyValidCharacters,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -38,12 +38,14 @@ export const createMockCommandContext = (
},
services: {
config: null,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
settings: {
merged: defaultMergedSettings,
setValue: vi.fn(),
forScope: vi.fn().mockReturnValue({ settings: {} }),
} as unknown as LoadedSettings,
git: undefined as GitService | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
logger: {
log: vi.fn(),
logMessage: vi.fn(),
@@ -52,6 +54,7 @@ export const createMockCommandContext = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any, // Cast because Logger is a class.
},
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
ui: {
addItem: vi.fn(),
clear: vi.fn(),
@@ -70,6 +73,7 @@ export const createMockCommandContext = (
} as any,
session: {
sessionShellAllowlist: new Set<string>(),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
stats: {
sessionStartTime: new Date(),
lastPromptTokenCount: 0,
+4 -1
View File
@@ -13,6 +13,7 @@ import { createTestMergedSettings } from '../config/settings.js';
* Creates a mocked Config object with default values and allows overrides.
*/
export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
({
getSandbox: vi.fn(() => undefined),
getQuestion: vi.fn(() => ''),
@@ -45,7 +46,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
setRemoteAdminSettings: vi.fn(),
isYoloModeDisabled: vi.fn(() => false),
isPlanEnabled: vi.fn(() => false),
isEventDrivenSchedulerEnabled: vi.fn(() => false),
getCoreTools: vi.fn(() => []),
getAllowedTools: vi.fn(() => []),
getApprovalMode: vi.fn(() => 'default'),
@@ -153,6 +153,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getBlockedMcpServers: vi.fn().mockReturnValue([]),
getExperiments: vi.fn().mockReturnValue(undefined),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
validatePathAccess: vi.fn().mockReturnValue(null),
...overrides,
}) as unknown as Config;
@@ -163,9 +164,11 @@ export function createMockSettings(
overrides: Record<string, unknown> = {},
): LoadedSettings {
const merged = createTestMergedSettings(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(overrides['merged'] as Partial<Settings>) || {},
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return {
system: { settings: {} },
systemDefaults: { settings: {} },

Some files were not shown because too many files have changed in this diff Show More