Compare commits

..

1 Commits

Author SHA1 Message Date
Abhi 1c4335686f feat(a2a): switch from callback-based to event-driven tool scheduler
This change transitions packages/a2a-server to use the event-driven
Scheduler by default. It replaces the legacy direct callback mechanism
with a MessageBus listener in the Task class to handle tool status
updates, live output, and confirmations.

- Added experimental.enableEventDrivenScheduler setting (defaults to true).
- Refactored Task.ts to support both legacy and event-driven schedulers.
- Implemented bus-based tool confirmation responses using correlationId.
- Exported Scheduler from packages/core.
- Added unit tests for the event-driven flow in A2A.
2026-01-20 15:50:00 -05:00
88 changed files with 1055 additions and 1963 deletions
+1 -8
View File
@@ -35,14 +35,7 @@ Follow these steps to create a Pull Request:
- **Related Issues**: Link any issues fixed or related to this PR (e.g.,
"Fixes #123").
4. **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.
5. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping
4. **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
+409 -61
View File
@@ -1,72 +1,420 @@
# Gemini CLI Project Context
## Building and running
Gemini CLI is an open-source AI agent that brings the power of Gemini directly
into the terminal. It is designed to be a terminal-first, extensible, and
powerful tool for developers.
Before submitting any changes, it is crucial to validate them by running the
full preflight check. This command will build the repository, run all tests,
check for type errors, and lint the code.
## Project Overview
To run the full suite of checks, execute the following command:
- **Purpose:** Provide a seamless terminal interface for Gemini models,
supporting code understanding, generation, automation, and integration via MCP
(Model Context Protocol).
- **Main Technologies:**
- **Runtime:** Node.js (>=20.0.0, recommended ~20.19.0 for development)
- **Language:** TypeScript
- **UI Framework:** React (using [Ink](https://github.com/vadimdemedes/ink)
for CLI rendering)
- **Testing:** Vitest
- **Bundling:** esbuild
- **Linting/Formatting:** ESLint, Prettier
- **Architecture:** Monorepo structure using npm workspaces.
- `packages/cli`: User-facing terminal UI, input processing, and display
rendering.
- `packages/core`: Backend logic, Gemini API orchestration, prompt
construction, and tool execution.
- `packages/core/src/tools/`: Built-in tools for file system, shell, and web
operations.
- `packages/a2a-server`: Experimental Agent-to-Agent server.
- `packages/vscode-ide-companion`: VS Code extension pairing with the CLI.
```bash
npm run preflight
```
## Building and Running
This single command ensures that your changes meet all the quality gates of the
project. While you can run the individual steps (`build`, `test`, `typecheck`,
`lint`) separately, it is highly recommended to use `npm run preflight` to
ensure a comprehensive validation.
- **Install Dependencies:** `npm install`
- **Build All:** `npm run build:all` (Builds packages, sandbox, and VS Code
companion)
- **Build Packages:** `npm run build`
- **Run in Development:** `npm run start`
- **Run in Debug Mode:** `npm run debug` (Enables Node.js inspector)
- **Bundle Project:** `npm run bundle`
- **Clean Artifacts:** `npm run clean`
## Running Tests in Workspaces\*\*: To run a specific test file within a
## Testing and Quality
workspace, use the command:
`npm test -w <workspace-name> -- <path/to/test-file.test.ts>`. **CRITICAL**: The
`<path/to/test-file.test.ts>` MUST be relative to the workspace directory root,
NOT the project root.
- **Test Commands:**
- **Unit (All):** `npm run test`
- **Integration (E2E):** `npm run test:e2e`
- **Workspace-Specific:** `npm test -w <pkg> -- <path>` (Note: `<path>` must
be relative to the workspace root, e.g.,
`-w @google/gemini-cli-core -- src/routing/modelRouterService.test.ts`)
- **Full Validation:** `npm run preflight` (Heaviest check; runs clean, install,
build, lint, type check, and tests. Recommended before submitting PRs.)
- **Individual Checks:** `npm run lint` / `npm run format` / `npm run typecheck`
- _Example (Core package)_:
`npm test -w @google/gemini-cli-core -- src/routing/modelRouterService.test.ts`
- _Common workspaces_: `@google/gemini-cli`, `@google/gemini-cli-core`.
## Development Conventions
## Writing Tests
- **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.
- **Commit Messages:** Follow the
[Conventional Commits](https://www.conventionalcommits.org/) standard.
- **Coding Style:** Adhere to existing patterns in `packages/cli` (React/Ink)
and `packages/core` (Backend logic).
- **Imports:** Use specific imports and avoid restricted relative imports
between packages (enforced by ESLint).
This project uses **Vitest** as its primary testing framework. When writing
tests, aim to follow existing patterns. Key conventions include:
## Documentation
### Test Structure and Framework
- Located in the `docs/` directory.
- Architecture overview: `docs/architecture.md`.
- Contribution guide: `CONTRIBUTING.md`.
- Documentation is organized via `docs/sidebar.json`.
- Follows the
[Google Developer Documentation Style Guide](https://developers.google.com/style).
- **Framework**: All tests are written using Vitest (`describe`, `it`, `expect`,
`vi`).
- **File Location**: Test files (`*.test.ts` for logic, `*.test.tsx` for React
components) are co-located with the source files they test.
- **Configuration**: Test environments are defined in `vitest.config.ts` files.
- **Setup/Teardown**: Use `beforeEach` and `afterEach`. Commonly,
`vi.resetAllMocks()` is called in `beforeEach` and `vi.restoreAllMocks()` in
`afterEach`.
### Mocking (`vi` from Vitest)
- **ES Modules**: Mock with
`vi.mock('module-name', async (importOriginal) => { ... })`. Use
`importOriginal` for selective mocking.
- _Example_:
`vi.mock('os', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, homedir: vi.fn() }; });`
- **Mocking Order**: For critical dependencies (e.g., `os`, `fs`) that affect
module-level constants, place `vi.mock` at the _very top_ of the test file,
before other imports.
- **Hoisting**: Use `const myMock = vi.hoisted(() => vi.fn());` if a mock
function needs to be defined before its use in a `vi.mock` factory.
- **Mock Functions**: Create with `vi.fn()`. Define behavior with
`mockImplementation()`, `mockResolvedValue()`, or `mockRejectedValue()`.
- **Spying**: Use `vi.spyOn(object, 'methodName')`. Restore spies with
`mockRestore()` in `afterEach`.
### Commonly Mocked Modules
- **Node.js built-ins**: `fs`, `fs/promises`, `os` (especially `os.homedir()`),
`path`, `child_process` (`execSync`, `spawn`).
- **External SDKs**: `@google/genai`, `@modelcontextprotocol/sdk`.
- **Internal Project Modules**: Dependencies from other project packages are
often mocked.
### React Component Testing (CLI UI - Ink)
- Use `render()` from `ink-testing-library`.
- Assert output with `lastFrame()`.
- Wrap components in necessary `Context.Provider`s.
- Mock custom React hooks and complex child components using `vi.mock()`.
### Asynchronous Testing
- Use `async/await`.
- For timers, use `vi.useFakeTimers()`, `vi.advanceTimersByTimeAsync()`,
`vi.runAllTimersAsync()`.
- Test promise rejections with `await expect(promise).rejects.toThrow(...)`.
### General Guidance
- When adding tests, first examine existing tests to understand and conform to
established conventions.
- Pay close attention to the mocks at the top of existing test files; they
reveal critical dependencies and how they are managed in a test environment.
## Git Repo
The main branch for this project is called "main"
## JavaScript/TypeScript
When contributing to this React, Node, and TypeScript codebase, please
prioritize the use of plain JavaScript objects with accompanying TypeScript
interface or type declarations over JavaScript class syntax. This approach
offers significant advantages, especially concerning interoperability with React
and overall code maintainability.
### Preferring Plain Objects over Classes
JavaScript classes, by their nature, are designed to encapsulate internal state
and behavior. While this can be useful in some object-oriented paradigms, it
often introduces unnecessary complexity and friction when working with React's
component-based architecture. Here's why plain objects are preferred:
- Seamless React Integration: React components thrive on explicit props and
state management. Classes' tendency to store internal state directly within
instances can make prop and state propagation harder to reason about and
maintain. Plain objects, on the other hand, are inherently immutable (when
used thoughtfully) and can be easily passed as props, simplifying data flow
and reducing unexpected side effects.
- Reduced Boilerplate and Increased Conciseness: Classes often promote the use
of constructors, this binding, getters, setters, and other boilerplate that
can unnecessarily bloat code. TypeScript interface and type declarations
provide powerful static type checking without the runtime overhead or
verbosity of class definitions. This allows for more succinct and readable
code, aligning with JavaScript's strengths in functional programming.
- Enhanced Readability and Predictability: Plain objects, especially when their
structure is clearly defined by TypeScript interfaces, are often easier to
read and understand. Their properties are directly accessible, and there's no
hidden internal state or complex inheritance chains to navigate. This
predictability leads to fewer bugs and a more maintainable codebase.
- Simplified Immutability: While not strictly enforced, plain objects encourage
an immutable approach to data. When you need to modify an object, you
typically create a new one with the desired changes, rather than mutating the
original. This pattern aligns perfectly with React's reconciliation process
and helps prevent subtle bugs related to shared mutable state.
- Better Serialization and Deserialization: Plain JavaScript objects are
naturally easy to serialize to JSON and deserialize back, which is a common
requirement in web development (e.g., for API communication or local storage).
Classes, with their methods and prototypes, can complicate this process.
### Embracing ES Module Syntax for Encapsulation
Rather than relying on Java-esque private or public class members, which can be
verbose and sometimes limit flexibility, we strongly prefer leveraging ES module
syntax (`import`/`export`) for encapsulating private and public APIs.
- Clearer Public API Definition: With ES modules, anything that is exported is
part of the public API of that module, while anything not exported is
inherently private to that module. This provides a very clear and explicit way
to define what parts of your code are meant to be consumed by other modules.
- Enhanced Testability (Without Exposing Internals): By default, unexported
functions or variables are not accessible from outside the module. This
encourages you to test the public API of your modules, rather than their
internal implementation details. If you find yourself needing to spy on or
stub an unexported function for testing purposes, it's often a "code smell"
indicating that the function might be a good candidate for extraction into its
own separate, testable module with a well-defined public API. This promotes a
more robust and maintainable testing strategy.
- Reduced Coupling: Explicitly defined module boundaries through import/export
help reduce coupling between different parts of your codebase. This makes it
easier to refactor, debug, and understand individual components in isolation.
### Avoiding `any` Types and Type Assertions; Preferring `unknown`
TypeScript's power lies in its ability to provide static type checking, catching
potential errors before your code runs. To fully leverage this, it's crucial to
avoid the `any` type and be judicious with type assertions.
- **The Dangers of `any`**: Using any effectively opts out of TypeScript's type
checking for that particular variable or expression. While it might seem
convenient in the short term, it introduces significant risks:
- **Loss of Type Safety**: You lose all the benefits of type checking, making
it easy to introduce runtime errors that TypeScript would otherwise have
caught.
- **Reduced Readability and Maintainability**: Code with `any` types is harder
to understand and maintain, as the expected type of data is no longer
explicitly defined.
- **Masking Underlying Issues**: Often, the need for any indicates a deeper
problem in the design of your code or the way you're interacting with
external libraries. It's a sign that you might need to refine your types or
refactor your code.
- **Preferring `unknown` over `any`**: When you absolutely cannot determine the
type of a value at compile time, and you're tempted to reach for any, consider
using unknown instead. unknown is a type-safe counterpart to any. While a
variable of type unknown can hold any value, you must perform type narrowing
(e.g., using typeof or instanceof checks, or a type assertion) before you can
perform any operations on it. This forces you to handle the unknown type
explicitly, preventing accidental runtime errors.
```ts
function processValue(value: unknown) {
if (typeof value === 'string') {
// value is now safely a string
console.log(value.toUpperCase());
} else if (typeof value === 'number') {
// value is now safely a number
console.log(value * 2);
}
// Without narrowing, you cannot access properties or methods on 'value'
// console.log(value.someProperty); // Error: Object is of type 'unknown'.
}
```
- **Type Assertions (`as Type`) - Use with Caution**: Type assertions tell the
TypeScript compiler, "Trust me, I know what I'm doing; this is definitely of
this type." While there are legitimate use cases (e.g., when dealing with
external libraries that don't have perfect type definitions, or when you have
more information than the compiler), they should be used sparingly and with
extreme caution.
- **Bypassing Type Checking**: Like `any`, type assertions bypass TypeScript's
safety checks. If your assertion is incorrect, you introduce a runtime error
that TypeScript would not have warned you about.
- **Code Smell in Testing**: A common scenario where `any` or type assertions
might be tempting is when trying to test "private" implementation details
(e.g., spying on or stubbing an unexported function within a module). This
is a strong indication of a "code smell" in your testing strategy and
potentially your code structure. Instead of trying to force access to
private internals, consider whether those internal details should be
refactored into a separate module with a well-defined public API. This makes
them inherently testable without compromising encapsulation.
### Type narrowing `switch` clauses
Use the `checkExhaustive` helper in the default clause of a switch statement.
This will ensure that all of the possible options within the value or
enumeration are used.
This helper method can be found in `packages/cli/src/utils/checks.ts`
### Embracing JavaScript's Array Operators
To further enhance code cleanliness and promote safe functional programming
practices, leverage JavaScript's rich set of array operators as much as
possible. Methods like `.map()`, `.filter()`, `.reduce()`, `.slice()`,
`.sort()`, and others are incredibly powerful for transforming and manipulating
data collections in an immutable and declarative way.
Using these operators:
- Promotes Immutability: Most array operators return new arrays, leaving the
original array untouched. This functional approach helps prevent unintended
side effects and makes your code more predictable.
- Improves Readability: Chaining array operators often lead to more concise and
expressive code than traditional for loops or imperative logic. The intent of
the operation is clear at a glance.
- Facilitates Functional Programming: These operators are cornerstones of
functional programming, encouraging the creation of pure functions that take
inputs and produce outputs without causing side effects. This paradigm is
highly beneficial for writing robust and testable code that pairs well with
React.
By consistently applying these principles, we can maintain a codebase that is
not only efficient and performant but also a joy to work with, both now and in
the future.
## React (mirrored and adjusted from [react-mcp-server](https://github.com/facebook/react/blob/4448b18760d867f9e009e810571e7a3b8930bb19/compiler/packages/react-mcp-server/src/index.ts#L376C1-L441C94))
### Role
You are a React assistant that helps users write more efficient and optimizable
React code. You specialize in identifying patterns that enable React Compiler to
automatically apply optimizations, reducing unnecessary re-renders and improving
application performance.
### Follow these guidelines in all code you produce and suggest
Use functional components with Hooks: Do not generate class components or use
old lifecycle methods. Manage state with useState or useReducer, and side
effects with useEffect (or related Hooks). Always prefer functions and Hooks for
any new component logic.
Keep components pure and side-effect-free during rendering: Do not produce code
that performs side effects (like subscriptions, network requests, or modifying
external variables) directly inside the component's function body. Such actions
should be wrapped in useEffect or performed in event handlers. Ensure your
render logic is a pure function of props and state.
Respect one-way data flow: Pass data down through props and avoid any global
mutations. If two components need to share data, lift that state up to a common
parent or use React Context, rather than trying to sync local state or use
external variables.
Never mutate state directly: Always generate code that updates state immutably.
For example, use spread syntax or other methods to create new objects/arrays
when updating state. Do not use assignments like state.someValue = ... or array
mutations like array.push() on state variables. Use the state setter (setState
from useState, etc.) to update state.
Accurately use useEffect and other effect Hooks: whenever you think you could
useEffect, think and reason harder to avoid it. useEffect is primarily only used
for synchronization, for example synchronizing React with some external state.
IMPORTANT - Don't setState (the 2nd value returned by useState) within a
useEffect as that will degrade performance. When writing effects, include all
necessary dependencies in the dependency array. Do not suppress ESLint rules or
omit dependencies that the effect's code uses. Structure the effect callbacks to
handle changing values properly (e.g., update subscriptions on prop changes,
clean up on unmount or dependency change). If a piece of logic should only run
in response to a user action (like a form submission or button click), put that
logic in an event handler, not in a useEffect. Where possible, useEffects should
return a cleanup function.
Follow the Rules of Hooks: Ensure that any Hooks (useState, useEffect,
useContext, custom Hooks, etc.) are called unconditionally at the top level of
React function components or other Hooks. Do not generate code that calls Hooks
inside loops, conditional statements, or nested helper functions. Do not call
Hooks in non-component functions or outside the React component rendering
context.
Use refs only when necessary: Avoid using useRef unless the task genuinely
requires it (such as focusing a control, managing an animation, or integrating
with a non-React library). Do not use refs to store application state that
should be reactive. If you do use refs, never write to or read from ref.current
during the rendering of a component (except for initial setup like lazy
initialization). Any ref usage should not affect the rendered output directly.
Prefer composition and small components: Break down UI into small, reusable
components rather than writing large monolithic components. The code you
generate should promote clarity and reusability by composing components
together. Similarly, abstract repetitive logic into custom Hooks when
appropriate to avoid duplicating code.
Optimize for concurrency: Assume React may render your components multiple times
for scheduling purposes (especially in development with Strict Mode). Write code
that remains correct even if the component function runs more than once. For
instance, avoid side effects in the component body and use functional state
updates (e.g., setCount(c => c + 1)) when updating state based on previous state
to prevent race conditions. Always include cleanup functions in effects that
subscribe to external resources. Don't write useEffects for "do this when this
changes" side effects. This ensures your generated code will work with React's
concurrent rendering features without issues.
Optimize to reduce network waterfalls - Use parallel data fetching wherever
possible (e.g., start multiple requests at once rather than one after another).
Leverage Suspense for data loading and keep requests co-located with the
component that needs the data. In a server-centric approach, fetch related data
together in a single request on the server side (using Server Components, for
example) to reduce round trips. Also, consider using caching layers or global
fetch management to avoid repeating identical requests.
Rely on React Compiler - useMemo, useCallback, and React.memo can be omitted if
React Compiler is enabled. Avoid premature optimization with manual memoization.
Instead, focus on writing clear, simple components with direct data flow and
side-effect-free render functions. Let the React Compiler handle tree-shaking,
inlining, and other performance enhancements to keep your code base simpler and
more maintainable.
Design for a good user experience - Provide clear, minimal, and non-blocking UI
states. When data is loading, show lightweight placeholders (e.g., skeleton
screens) rather than intrusive spinners everywhere. Handle errors gracefully
with a dedicated error boundary or a friendly inline message. Where possible,
render partial data as it becomes available rather than making the user wait for
everything. Suspense allows you to declare the loading states in your component
tree in a natural way, preventing “flash” states and improving perceived
performance.
### Process
1. Analyze the user's code for optimization opportunities:
- Check for React anti-patterns that prevent compiler optimization
- Look for component structure issues that limit compiler effectiveness
- Think about each suggestion you are making and consult React docs for best
practices
2. Provide actionable guidance:
- Explain specific code changes with clear reasoning
- Show before/after examples when suggesting changes
- Only suggest changes that meaningfully improve optimization potential
### Optimization Guidelines
- State updates should be structured to enable granular updates
- Side effects should be isolated and dependencies clearly defined
## Documentation guidelines
When working in the `/docs` directory, follow the guidelines in this section:
- **Role:** You are an expert technical writer and AI assistant for contributors
to Gemini CLI. Produce professional, accurate, and consistent documentation to
guide users of Gemini CLI.
- **Technical Accuracy:** Do not invent facts, commands, code, API names, or
output. All technical information specific to Gemini CLI must be based on code
found within this directory and its subdirectories.
- **Style Authority:** Your source for writing guidance and style is the
"Documentation contribution process" section in the root directory's
`CONTRIBUTING.md` file, as well as any guidelines provided this section.
- **Information Architecture Consideration:** Before proposing documentation
changes, consider the information architecture. If a change adds significant
new content to existing documents, evaluate if creating a new, more focused
page or changes to `sidebar.json` would provide a better user experience.
- **Proactive User Consideration:** The user experience should be a primary
concern when making changes to documentation. Aim to fill gaps in existing
knowledge whenever possible while keeping documentation concise and easy for
users to understand. If changes might hinder user understanding or
accessibility, proactively raise these concerns and propose alternatives.
## Comments policy
Only write high-value comments if at all. Avoid talking to the user through
comments.
## Logging and Error Handling
- **Avoid Console Statements:** Do not use `console.log`, `console.error`, or
similar methods directly.
- **Non-User-Facing Logs:** For developer-facing debug messages, use
`debugLogger` (from `@google/gemini-cli-core`).
- **User-Facing Feedback:** To surface errors or warnings to the user, use
`coreEvents.emitFeedback` (from `@google/gemini-cli-core`).
## General requirements
- If there is something you do not understand or is ambiguous, seek confirmation
or clarification from the user before making changes based on assumptions.
- Use hyphens instead of underscores in flag names (e.g. `my-flag` instead of
`my_flag`).
- Always refer to Gemini CLI as `Gemini CLI`, never `the Gemini CLI`.
+11 -12
View File
@@ -19,8 +19,8 @@ available combinations.
| Action | Keys |
| ------------------------------------------- | ------------------------------------------------------------ |
| Move the cursor to the start of the line. | `Ctrl + A`<br />`Home (no Ctrl, no Shift)` |
| Move the cursor to the end of the line. | `Ctrl + E`<br />`End (no Ctrl, no Shift)` |
| Move the cursor to the start of the line. | `Ctrl + A`<br />`Home` |
| Move the cursor to the end of the line. | `Ctrl + E`<br />`End` |
| Move the cursor up one line. | `Up Arrow (no Ctrl, no Cmd)` |
| Move the cursor down one line. | `Down Arrow (no Ctrl, no Cmd)` |
| Move the cursor one character to the left. | `Left Arrow (no Ctrl, no Cmd)`<br />`Ctrl + B` |
@@ -44,14 +44,14 @@ available combinations.
#### Scrolling
| Action | Keys |
| ------------------------ | --------------------------------- |
| Scroll content up. | `Shift + Up Arrow` |
| Scroll content down. | `Shift + Down Arrow` |
| Scroll to the top. | `Ctrl + Home`<br />`Shift + Home` |
| Scroll to the bottom. | `Ctrl + End`<br />`Shift + End` |
| Scroll up by one page. | `Page Up` |
| Scroll down by one page. | `Page Down` |
| Action | Keys |
| ------------------------ | -------------------- |
| Scroll content up. | `Shift + Up Arrow` |
| Scroll content down. | `Shift + Down Arrow` |
| Scroll to the top. | `Home` |
| Scroll to the bottom. | `End` |
| Scroll up by one page. | `Page Up` |
| Scroll down by one page. | `Page Down` |
#### History & Search
@@ -117,8 +117,7 @@ available combinations.
- `!` on an empty prompt: Enter or exit shell mode.
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
mode.
- `Esc` pressed twice quickly: Clear the input prompt if it is not empty,
otherwise browse and rewind previous interactions.
- `Esc` pressed twice quickly: Browse and rewind previous interactions.
- `Up Arrow` / `Down Arrow`: When the cursor is at the top or bottom of a
single-line input, navigate backward or forward through prompt history.
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
+4 -4
View File
@@ -122,10 +122,10 @@ they appear in the UI.
| Enable CLI Help Agent | `experimental.cliHelpAgentSettings.enabled` | Enable the CLI Help Agent. | `true` |
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
### HooksConfig
### Hooks
| UI Label | Setting | Description | Default |
| ------------------ | --------------------------- | ------------------------------------------------ | ------- |
| Hook Notifications | `hooksConfig.notifications` | Show visual indicators when hooks are executing. | `true` |
| UI Label | Setting | Description | Default |
| ------------------ | --------------------- | ------------------------------------------------ | ------- |
| Hook Notifications | `hooks.notifications` | Show visual indicators when hooks are executing. | `true` |
<!-- SETTINGS-AUTOGEN:END -->
+5 -7
View File
@@ -904,24 +904,22 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `[]`
- **Requires restart:** Yes
#### `hooksConfig`
#### `hooks`
- **`hooksConfig.enabled`** (boolean):
- **`hooks.enabled`** (boolean):
- **Description:** Canonical toggle for the hooks system. When disabled, no
hooks will be executed.
- **Default:** `true`
- **Default:** `false`
- **`hooksConfig.disabled`** (array):
- **`hooks.disabled`** (array):
- **Description:** List of hook names (commands) that should be disabled.
Hooks in this list will not execute even if configured.
- **Default:** `[]`
- **`hooksConfig.notifications`** (boolean):
- **`hooks.notifications`** (boolean):
- **Description:** Show visual indicators when hooks are executing.
- **Default:** `true`
#### `hooks`
- **`hooks.BeforeTool`** (array):
- **Description:** Hooks that execute before tool execution. Can intercept,
validate, or modify tool calls.
+4
View File
@@ -80,6 +80,10 @@
"label": "Model selection",
"slug": "docs/cli/model"
},
{
"label": "Rewind",
"slug": "docs/cli/rewind"
},
{
"label": "Sandbox",
"slug": "docs/cli/sandbox"
+3 -9
View File
@@ -53,10 +53,8 @@ describe('Hooks Agent Flow', () => {
await rig.setup('should inject additional context via BeforeAgent hook', {
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
BeforeAgent: [
{
hooks: [
@@ -118,10 +116,8 @@ describe('Hooks Agent Flow', () => {
await rig.setup('should receive prompt and response in AfterAgent hook', {
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
AfterAgent: [
{
hooks: [
@@ -167,10 +163,8 @@ describe('Hooks Agent Flow', () => {
'hooks-agent-flow-multistep.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
BeforeAgent: [
{
hooks: [
+29 -83
View File
@@ -32,10 +32,8 @@ describe('Hooks System Integration', () => {
'hooks-system.block-tool.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
matcher: 'write_file',
@@ -86,10 +84,8 @@ describe('Hooks System Integration', () => {
'hooks-system.block-tool.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
matcher: 'write_file',
@@ -145,10 +141,8 @@ describe('Hooks System Integration', () => {
'hooks-system.allow-tool.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
matcher: 'write_file',
@@ -195,10 +189,8 @@ describe('Hooks System Integration', () => {
'hooks-system.after-tool-context.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
AfterTool: [
{
matcher: 'read_file',
@@ -270,10 +262,8 @@ console.log(JSON.stringify({
rig.setup('should modify LLM requests with BeforeModel hooks', {
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
BeforeModel: [
{
hooks: [
@@ -331,10 +321,8 @@ console.log(JSON.stringify({
'should block model execution when BeforeModel hook returns deny decision',
{
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
BeforeModel: [
{
hooks: [
@@ -376,10 +364,8 @@ console.log(JSON.stringify({
'should block model execution when BeforeModel hook returns block decision',
{
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
BeforeModel: [
{
hooks: [
@@ -443,10 +429,8 @@ console.log(JSON.stringify({
rig.setup('should modify LLM responses with AfterModel hooks', {
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
AfterModel: [
{
hooks: [
@@ -491,10 +475,8 @@ console.log(JSON.stringify({
rig.setup('should modify tool selection with BeforeToolSelection hooks', {
settings: {
debugMode: true,
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
BeforeToolSelection: [
{
hooks: [
@@ -558,10 +540,8 @@ console.log(JSON.stringify({
rig.setup('should augment prompts with BeforeAgent hooks', {
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
BeforeAgent: [
{
hooks: [
@@ -606,10 +586,8 @@ console.log(JSON.stringify({
approval: 'ASK', // Disable YOLO mode to show permission prompts
confirmationRequired: ['run_shell_command'],
},
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
Notification: [
{
matcher: 'ToolPermission',
@@ -699,10 +677,8 @@ console.log(JSON.stringify({
'hooks-system.sequential-execution.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
BeforeAgent: [
{
sequential: true,
@@ -781,10 +757,8 @@ try {
rig.setup('should provide correct input format to hooks', {
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
hooks: [
@@ -826,10 +800,8 @@ try {
'hooks-system.allow-tool.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
matcher: 'write_file',
@@ -880,10 +852,8 @@ try {
'hooks-system.multiple-events.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
BeforeAgent: [
{
hooks: [
@@ -995,10 +965,8 @@ try {
rig.setup('should handle hook failures gracefully', {
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
hooks: [
@@ -1049,10 +1017,8 @@ try {
'hooks-system.telemetry.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
hooks: [
@@ -1092,10 +1058,8 @@ try {
'hooks-system.session-startup.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'startup',
@@ -1165,10 +1129,8 @@ console.log(JSON.stringify({
rig.setup('should fire SessionStart hook and inject context', {
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'startup',
@@ -1250,10 +1212,8 @@ console.log(JSON.stringify({
'should fire SessionStart hook and display systemMessage in interactive mode',
{
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'startup',
@@ -1320,10 +1280,8 @@ console.log(JSON.stringify({
'hooks-system.session-clear.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
SessionEnd: [
{
matcher: '*',
@@ -1494,10 +1452,8 @@ console.log(JSON.stringify({
'hooks-system.compress-auto.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
PreCompress: [
{
matcher: 'auto',
@@ -1561,10 +1517,8 @@ console.log(JSON.stringify({
'hooks-system.session-startup.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
SessionEnd: [
{
matcher: 'exit',
@@ -1661,11 +1615,8 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
rig.setup('should not execute hooks disabled in settings file', {
settings: {
hooksConfig: {
enabled: true,
disabled: [`node "${disabledPath}"`], // Disable the second hook
},
hooks: {
enabled: true,
BeforeTool: [
{
hooks: [
@@ -1682,6 +1633,7 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
],
},
],
disabled: [`node "${disabledPath}"`], // Disable the second hook
},
},
});
@@ -1738,11 +1690,8 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
rig.setup('should respect disabled hooks across multiple operations', {
settings: {
hooksConfig: {
enabled: true,
disabled: [`node "${disabledPath}"`], // Disable the second hook,
},
hooks: {
enabled: true,
BeforeTool: [
{
hooks: [
@@ -1759,6 +1708,7 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
],
},
],
disabled: [`node "${disabledPath}"`], // Disable the second hook
},
},
});
@@ -1845,10 +1795,8 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
'hooks-system.input-modification.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
matcher: 'write_file',
@@ -1931,10 +1879,8 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
'hooks-system.before-tool-stop.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
matcher: 'write_file',
+33 -22
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.26.0-preview.2",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.26.0-preview.2",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"workspaces": [
"packages/*"
],
@@ -2474,6 +2474,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",
@@ -2654,6 +2655,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"
}
@@ -2687,6 +2689,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"
},
@@ -3055,6 +3058,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"
@@ -3088,6 +3092,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"
@@ -3140,6 +3145,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",
@@ -4352,6 +4358,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4629,6 +4636,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",
@@ -5633,6 +5641,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"
},
@@ -6077,8 +6086,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/array-includes": {
"version": "3.1.9",
@@ -7362,7 +7370,6 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "5.2.1"
},
@@ -8682,6 +8689,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -9284,7 +9292,6 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -9294,7 +9301,6 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -9304,7 +9310,6 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -9558,7 +9563,6 @@
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
@@ -9577,7 +9581,6 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -9586,15 +9589,13 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/finalhandler/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -10877,6 +10878,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz",
"integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -14061,8 +14063,7 @@
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/path-type": {
"version": "3.0.0",
@@ -14639,6 +14640,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14649,6 +14651,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -16908,6 +16911,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17131,7 +17135,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",
@@ -17139,6 +17144,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"
@@ -17322,6 +17328,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -17484,7 +17491,6 @@
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.4.0"
}
@@ -17539,6 +17545,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",
@@ -17652,6 +17659,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17664,6 +17672,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",
@@ -18368,6 +18377,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"
}
@@ -18383,7 +18393,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.26.0-preview.2",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
"@google-cloud/storage": "^7.16.0",
@@ -18693,7 +18703,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.26.0-preview.2",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -18797,7 +18807,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.26.0-preview.2",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
@@ -18934,6 +18944,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -18956,7 +18967,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.26.0-preview.2",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18973,7 +18984,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.26.0-preview.2",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.26.0-preview.2",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-preview.2"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-nightly.20260115.6cb3ae4e0"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.26.0-preview.2",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -0,0 +1,173 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { Task } from './task.js';
import {
type Config,
MessageBusType,
ToolConfirmationOutcome,
Scheduler,
type MessageBus,
} from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
describe('Task Event-Driven Scheduler', () => {
let mockConfig: Config;
let mockEventBus: ExecutionEventBus;
let messageBus: MessageBus;
beforeEach(() => {
vi.clearAllMocks();
mockConfig = createMockConfig({
isEventDrivenSchedulerEnabled: () => true,
}) as Config;
messageBus = mockConfig.getMessageBus();
mockEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
});
it('should instantiate Scheduler when enabled', () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
expect(task.scheduler).toBeInstanceOf(Scheduler);
});
it('should subscribe to TOOL_CALLS_UPDATE and map status changes', async () => {
// @ts-expect-error - Calling private constructor
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'ls', args: {} },
status: 'executing',
};
// Simulate MessageBus event
// Simulate MessageBus event
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
if (!handler) {
throw new Error('TOOL_CALLS_UPDATE handler not found');
}
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
});
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
status: expect.objectContaining({
state: 'submitted', // initial task state
}),
metadata: expect.objectContaining({
coderAgent: expect.objectContaining({
kind: 'tool-call-update',
}),
}),
}),
);
});
it('should handle tool confirmations by publishing to MessageBus', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'ls', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-1',
confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
};
// Simulate MessageBus event to stash the correlationId
// Simulate MessageBus event
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
if (!handler) {
throw new Error('TOOL_CALLS_UPDATE handler not found');
}
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
});
// Simulate A2A client confirmation
const part = {
kind: 'data',
data: {
callId: '1',
outcome: 'proceed_once',
},
};
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart(part);
expect(handled).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-1',
confirmed: true,
outcome: ToolConfirmationOutcome.ProceedOnce,
}),
);
});
it('should handle output updates via the message bus', async () => {
// @ts-expect-error - Calling private constructor
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'ls', args: {} },
status: 'executing',
liveOutput: 'chunk1',
};
// Simulate MessageBus event
// Simulate MessageBus event
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
if (!handler) {
throw new Error('TOOL_CALLS_UPDATE handler not found');
}
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
});
// Should publish artifact update for output
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'artifact-update',
artifact: expect.objectContaining({
artifactId: 'tool-1-output',
parts: [{ kind: 'text', text: 'chunk1' }],
}),
}),
);
});
});
+192 -22
View File
@@ -5,6 +5,7 @@
*/
import {
Scheduler,
CoreToolScheduler,
type GeminiClient,
GeminiEventType,
@@ -29,6 +30,9 @@ import {
type AnsiOutput,
EDIT_TOOL_NAMES,
processRestorableToolCalls,
MessageBusType,
type ToolCallsUpdateMessage,
type SerializableConfirmationDetails,
} from '@google/gemini-cli-core';
import type { RequestContext } from '@a2a-js/sdk/server';
import { type ExecutionEventBus } from '@a2a-js/sdk/server';
@@ -62,10 +66,11 @@ type UnionKeys<T> = T extends T ? keyof T : never;
export class Task {
id: string;
contextId: string;
scheduler: CoreToolScheduler;
scheduler: Scheduler | CoreToolScheduler;
config: Config;
geminiClient: GeminiClient;
pendingToolConfirmationDetails: Map<string, ToolCallConfirmationDetails>;
pendingCorrelationIds: Map<string, string> = new Map();
taskState: TaskState;
eventBus?: ExecutionEventBus;
completedToolCalls: CompletedToolCall[];
@@ -93,7 +98,13 @@ export class Task {
this.id = id;
this.contextId = contextId;
this.config = config;
this.scheduler = this.createScheduler();
if (this.config.isEventDrivenSchedulerEnabled()) {
this.scheduler = this._setupEventDrivenScheduler();
} else {
this.scheduler = this.createLegacyScheduler();
}
this.geminiClient = this.config.getGeminiClient();
this.pendingToolConfirmationDetails = new Map();
this.taskState = 'submitted';
@@ -206,6 +217,13 @@ export class Task {
this.toolCompletionNotifier.reject(new Error(reason));
}
this.pendingToolCalls.clear();
this.pendingCorrelationIds.clear();
if (this.scheduler instanceof Scheduler) {
this.scheduler.cancelAll();
} else {
this.scheduler.cancelAll(new AbortController().signal);
}
// Reset the promise for any future operations, ensuring it's in a clean state.
this._resetToolCompletionPromise();
}
@@ -450,7 +468,7 @@ export class Task {
}
}
private createScheduler(): CoreToolScheduler {
private createLegacyScheduler(): CoreToolScheduler {
const scheduler = new CoreToolScheduler({
outputUpdateHandler: this._schedulerOutputUpdate.bind(this),
onAllToolCallsComplete: this._schedulerAllToolCallsComplete.bind(this),
@@ -461,6 +479,134 @@ export class Task {
return scheduler;
}
private _setupEventDrivenScheduler(): Scheduler {
const messageBus = this.config.getMessageBus();
const scheduler = new Scheduler({
config: this.config,
messageBus,
getPreferredEditor: () => DEFAULT_GUI_EDITOR,
});
messageBus.subscribe(
MessageBusType.TOOL_CALLS_UPDATE,
(message: unknown) => {
const event = message as ToolCallsUpdateMessage;
if (event.type !== MessageBusType.TOOL_CALLS_UPDATE) {
return;
}
const toolCalls = event.toolCalls;
toolCalls.forEach((tc) => {
const callId = tc.request.callId;
const previousStatus = this.pendingToolCalls.get(callId);
const hasChanged = previousStatus !== tc.status;
// 1. Handle Output
if (tc.status === 'executing' && tc.liveOutput) {
this._schedulerOutputUpdate(callId, tc.liveOutput);
}
// 2. Handle terminal states
if (['success', 'error', 'cancelled'].includes(tc.status)) {
if (hasChanged) {
const completedCall = tc as CompletedToolCall;
logger.info(
`[Task] Tool call ${callId} completed with status: ${tc.status}`,
);
this.completedToolCalls.push(completedCall);
this._resolveToolCall(callId);
}
} else {
// Keep track of pending tools
this._registerToolCall(callId, tc.status);
}
// 3. Handle Confirmation Stash
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
// Bridge the new serializable details back to the legacy shape for A2A UI
const details =
tc.confirmationDetails as SerializableConfirmationDetails;
if (tc.correlationId) {
this.pendingCorrelationIds.set(callId, tc.correlationId);
}
// In A2A, we just need to store the details so the client can fetch them.
// The actual confirmation will be handled by _handleToolConfirmationPart
// publishing back to the bus using the correlationId.
this.pendingToolConfirmationDetails.set(callId, {
...details,
// Inject a dummy onConfirm for legacy UI compatibility if needed,
// though A2A should use the correlationId-based path now.
onConfirm: async () => {},
} as ToolCallConfirmationDetails);
}
// 4. Publish Status Updates to A2A event bus
if (hasChanged) {
const coderAgentMessage: CoderAgentMessage =
tc.status === 'awaiting_approval'
? { kind: CoderAgentEvent.ToolCallConfirmationEvent }
: { kind: CoderAgentEvent.ToolCallUpdateEvent };
const message = this.toolStatusMessage(tc, this.id, this.contextId);
const statusUpdate = this._createStatusUpdateEvent(
this.taskState,
coderAgentMessage,
message,
false,
);
this.eventBus?.publish(statusUpdate);
}
// 5. Handle Auto-Execution (YOLO)
if (
tc.status === 'awaiting_approval' &&
tc.correlationId &&
(this.autoExecute ||
this.config.getApprovalMode() === ApprovalMode.YOLO)
) {
logger.info(`[Task] Auto-approving tool call ${callId}`);
void messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: tc.correlationId,
confirmed: true,
outcome: ToolConfirmationOutcome.ProceedOnce,
});
this.pendingToolConfirmationDetails.delete(callId);
}
});
// 6. Handle Input Required State
const allPendingStatuses = Array.from(this.pendingToolCalls.values());
const isAwaitingApproval = allPendingStatuses.some(
(status) => status === 'awaiting_approval',
);
const isExecuting = allPendingStatuses.some(
(status) => status === 'executing',
);
if (
isAwaitingApproval &&
!isExecuting &&
!this.skipFinalTrueAfterInlineEdit
) {
this.skipFinalTrueAfterInlineEdit = false;
this.setTaskStateAndPublishUpdate(
'input-required',
{ kind: CoderAgentEvent.StateChangeEvent },
undefined,
undefined,
/*final*/ true,
);
}
},
);
return scheduler;
}
private _pickFields<
T extends ToolCall | AnyDeclarativeTool,
K extends UnionKeys<T>,
@@ -640,7 +786,11 @@ export class Task {
};
this.setTaskStateAndPublishUpdate('working', stateChange);
await this.scheduler.schedule(updatedRequests, abortSignal);
if (this.scheduler instanceof Scheduler) {
await this.scheduler.schedule(updatedRequests, abortSignal);
} else {
await this.scheduler.schedule(updatedRequests, abortSignal);
}
}
async acceptAgentMessage(event: ServerGeminiStreamEvent): Promise<void> {
@@ -780,8 +930,9 @@ export class Task {
}
const confirmationDetails = this.pendingToolConfirmationDetails.get(callId);
const correlationId = this.pendingCorrelationIds.get(callId);
if (!confirmationDetails) {
if (!confirmationDetails && !correlationId) {
logger.warn(
`[Task] Received tool confirmation for unknown or already processed callId: ${callId}`,
);
@@ -803,24 +954,42 @@ export class Task {
// This will trigger the scheduler to continue or cancel the specific tool.
// The scheduler's onToolCallsUpdate will then reflect the new state (e.g., executing or cancelled).
// If `edit` tool call, pass updated payload if presesent
if (confirmationDetails.type === 'edit') {
const payload = part.data['newContent']
? ({
newContent: part.data['newContent'] as string,
} as ToolConfirmationPayload)
: undefined;
this.skipFinalTrueAfterInlineEdit = !!payload;
try {
await confirmationDetails.onConfirm(confirmationOutcome, payload);
} finally {
// Once confirmationDetails.onConfirm finishes (or fails) with a payload,
// reset skipFinalTrueAfterInlineEdit so that external callers receive
// their call has been completed.
this.skipFinalTrueAfterInlineEdit = false;
if (correlationId) {
const payload =
confirmationDetails?.type === 'edit' && part.data['newContent']
? ({
newContent: part.data['newContent'] as string,
} as ToolConfirmationPayload)
: undefined;
await this.config.getMessageBus().publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId,
confirmed: confirmationOutcome !== ToolConfirmationOutcome.Cancel,
outcome: confirmationOutcome,
payload,
});
} else if (confirmationDetails) {
// Legacy path
// If `edit` tool call, pass updated payload if presesent
if (confirmationDetails.type === 'edit') {
const payload = part.data['newContent']
? ({
newContent: part.data['newContent'] as string,
} as ToolConfirmationPayload)
: undefined;
this.skipFinalTrueAfterInlineEdit = !!payload;
try {
await confirmationDetails.onConfirm(confirmationOutcome, payload);
} finally {
// Once confirmationDetails.onConfirm finishes (or fails) with a payload,
// reset skipFinalTrueAfterInlineEdit so that external callers receive
// their call has been completed.
this.skipFinalTrueAfterInlineEdit = false;
}
} else {
await confirmationDetails.onConfirm(confirmationOutcome);
}
} else {
await confirmationDetails.onConfirm(confirmationOutcome);
}
} finally {
if (gcpProject) {
@@ -836,6 +1005,7 @@ export class Task {
// Note !== ToolConfirmationOutcome.ModifyWithEditor does not work!
if (confirmationOutcome !== 'modify_with_editor') {
this.pendingToolConfirmationDetails.delete(callId);
this.pendingCorrelationIds.delete(callId);
}
// If outcome is Cancel, scheduler should update status to 'cancelled', which then resolves the tool.
+2
View File
@@ -95,6 +95,8 @@ export async function loadConfig(
extensionLoader,
checkpointing,
previewFeatures: settings.general?.previewFeatures,
enableEventDrivenScheduler:
settings.experimental?.enableEventDrivenScheduler ?? true,
interactive: true,
enableInteractiveShell: true,
};
@@ -34,6 +34,9 @@ export interface Settings {
general?: {
previewFeatures?: boolean;
};
experimental?: {
enableEventDrivenScheduler?: boolean;
};
// Git-aware file filtering settings
fileFiltering?: {
@@ -60,6 +60,7 @@ export function createMockConfig(
getEmbeddingModel: vi.fn().mockReturnValue('text-embedding-004'),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getUserTier: vi.fn(),
isEventDrivenSchedulerEnabled: vi.fn().mockReturnValue(false),
getMessageBus: vi.fn(),
getPolicyEngine: vi.fn(),
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.26.0-preview.2",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-preview.2"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-nightly.20260115.6cb3ae4e0"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -511,5 +511,8 @@ describe('migrate command', () => {
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
);
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
'Note: Set hooks.enabled to true in your settings to enable the hook system.',
);
});
});
+4 -4
View File
@@ -230,10 +230,7 @@ export async function handleMigrateFromClaude() {
const settings = loadSettings(workingDir);
// Merge migrated hooks with existing hooks
const existingHooks = (settings.merged?.hooks || {}) as Record<
string,
unknown
>;
const existingHooks = settings.merged.hooks as Record<string, unknown>;
const mergedHooks = { ...existingHooks, ...migratedHooks };
// Update settings (setValue automatically saves)
@@ -244,6 +241,9 @@ export async function handleMigrateFromClaude() {
debugLogger.log(
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
);
debugLogger.log(
'Note: Set hooks.enabled to true in your settings to enable the hook system.',
);
} catch (error) {
debugLogger.error(`Error saving migrated hooks: ${getErrorMessage(error)}`);
}
+1 -16
View File
@@ -123,13 +123,8 @@ describe('mcp list command', () => {
...defaultMergedSettings,
mcpServers: {
'stdio-server': { command: '/path/to/server', args: ['arg1'] },
'sse-server': { url: 'https://example.com/sse', type: 'sse' },
'sse-server': { url: 'https://example.com/sse' },
'http-server': { httpUrl: 'https://example.com/http' },
'http-server-by-default': { url: 'https://example.com/http' },
'http-server-with-type': {
url: 'https://example.com/http',
type: 'http',
},
},
},
});
@@ -155,16 +150,6 @@ describe('mcp list command', () => {
'http-server: https://example.com/http (http) - Connected',
),
);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'http-server-by-default: https://example.com/http (http) - Connected',
),
);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'http-server-with-type: https://example.com/http (http) - Connected',
),
);
});
it('should display disconnected status when connection fails', async () => {
+1 -2
View File
@@ -144,8 +144,7 @@ export async function listMcpServers(): Promise<void> {
if (server.httpUrl) {
serverInfo += `${server.httpUrl} (http)`;
} else if (server.url) {
const type = server.type || 'http';
serverInfo += `${server.url} (${type})`;
serverInfo += `${server.url} (sse)`;
} else if (server.command) {
serverInfo += `${server.command} ${server.args?.join(' ') || ''} (stdio)`;
}
+1 -4
View File
@@ -451,7 +451,6 @@ export async function loadCliConfig(
workspaceDir: cwd,
enabledExtensionOverrides: argv.extensions,
eventEmitter: appEvents as EventEmitter<ExtensionEvents>,
clientVersion: await getVersion(),
});
await extensionManager.loadExtensions();
@@ -654,7 +653,6 @@ export async function loadCliConfig(
return new Config({
sessionId,
clientVersion: await getVersion(),
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: sandboxConfig,
targetDir: cwd,
@@ -763,10 +761,9 @@ export async function loadCliConfig(
// TODO: loading of hooks based on workspace trust
enableHooks:
(settings.tools?.enableHooks ?? true) &&
(settings.hooksConfig?.enabled ?? true),
(settings.hooks?.enabled ?? false),
enableHooksUI: settings.tools?.enableHooks ?? true,
hooks: settings.hooks || {},
disabledHooks: settings.hooksConfig?.disabled || [],
projectHooks: projectHooks || {},
onModelChange: (model: string) => saveModelChange(loadedSettings, model),
onReload: async () => {
+1 -6
View File
@@ -76,7 +76,6 @@ interface ExtensionManagerParams {
requestSetting: ((setting: ExtensionSetting) => Promise<string>) | null;
workspaceDir: string;
eventEmitter?: EventEmitter<ExtensionEvents>;
clientVersion?: string;
}
/**
@@ -106,7 +105,6 @@ export class ExtensionManager extends ExtensionLoader {
telemetry: options.settings.telemetry,
interactive: false,
sessionId: randomUUID(),
clientVersion: options.clientVersion ?? 'unknown',
targetDir: options.workspaceDir,
cwd: options.workspaceDir,
model: '',
@@ -613,10 +611,7 @@ Would you like to attempt to install via "git clone" instead?`,
.filter((contextFilePath) => fs.existsSync(contextFilePath));
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
if (
this.settings.tools.enableHooks &&
this.settings.hooksConfig.enabled
) {
if (this.settings.tools.enableHooks && this.settings.hooks.enabled) {
hooks = await this.loadExtensionHooks(effectiveExtensionPath, {
extensionPath: effectiveExtensionPath,
workspacePath: this.workspaceDir,
+3 -4
View File
@@ -815,7 +815,6 @@ describe('extension tests', () => {
fs.mkdirSync(hooksDir);
const hooksConfig = {
enabled: false,
hooks: {
BeforeTool: [
{
@@ -837,7 +836,7 @@ describe('extension tests', () => {
);
const settings = loadSettings(tempWorkspaceDir).merged;
settings.hooksConfig.enabled = true;
settings.hooks.enabled = true;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
@@ -868,11 +867,11 @@ describe('extension tests', () => {
fs.mkdirSync(hooksDir);
fs.writeFileSync(
path.join(hooksDir, 'hooks.json'),
JSON.stringify({ hooks: { BeforeTool: [] }, enabled: false }),
JSON.stringify({ hooks: { BeforeTool: [] } }),
);
const settings = loadSettings(tempWorkspaceDir).merged;
settings.hooksConfig.enabled = false;
settings.hooks.enabled = false;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
+3 -21
View File
@@ -73,27 +73,9 @@ describe('keyBindings config', () => {
expect(dialogNavDown).toContainEqual({ key: 'down', shift: false });
expect(dialogNavDown).toContainEqual({ key: 'j', shift: false });
// Verify physical home/end keys for cursor movement
expect(defaultKeyBindings[Command.HOME]).toContainEqual({
key: 'home',
ctrl: false,
shift: false,
});
expect(defaultKeyBindings[Command.END]).toContainEqual({
key: 'end',
ctrl: false,
shift: false,
});
// Verify physical home/end keys for scrolling
expect(defaultKeyBindings[Command.SCROLL_HOME]).toContainEqual({
key: 'home',
ctrl: true,
});
expect(defaultKeyBindings[Command.SCROLL_END]).toContainEqual({
key: 'end',
ctrl: true,
});
// Verify physical home/end keys
expect(defaultKeyBindings[Command.HOME]).toContainEqual({ key: 'home' });
expect(defaultKeyBindings[Command.END]).toContainEqual({ key: 'end' });
});
});
+4 -16
View File
@@ -117,14 +117,8 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.EXIT]: [{ key: 'd', ctrl: true }],
// Cursor Movement
[Command.HOME]: [
{ key: 'a', ctrl: true },
{ key: 'home', ctrl: false, shift: false },
],
[Command.END]: [
{ key: 'e', ctrl: true },
{ key: 'end', ctrl: false, shift: false },
],
[Command.HOME]: [{ key: 'a', ctrl: true }, { key: 'home' }],
[Command.END]: [{ key: 'e', ctrl: true }, { key: 'end' }],
[Command.MOVE_UP]: [{ key: 'up', ctrl: false, command: false }],
[Command.MOVE_DOWN]: [{ key: 'down', ctrl: false, command: false }],
[Command.MOVE_LEFT]: [
@@ -168,14 +162,8 @@ export const defaultKeyBindings: KeyBindingConfig = {
// Scrolling
[Command.SCROLL_UP]: [{ key: 'up', shift: true }],
[Command.SCROLL_DOWN]: [{ key: 'down', shift: true }],
[Command.SCROLL_HOME]: [
{ key: 'home', ctrl: true },
{ key: 'home', shift: true },
],
[Command.SCROLL_END]: [
{ key: 'end', ctrl: true },
{ key: 'end', shift: true },
],
[Command.SCROLL_HOME]: [{ key: 'home' }],
[Command.SCROLL_END]: [{ key: 'end' }],
[Command.PAGE_UP]: [{ key: 'pageup' }],
[Command.PAGE_DOWN]: [{ key: 'pagedown' }],
-51
View File
@@ -1961,57 +1961,6 @@ describe('Settings Loading and Merging', () => {
}),
);
});
it('should migrate disableUpdateNag to enableAutoUpdateNotification in system and system defaults settings', () => {
const systemSettingsContent = {
general: {
disableUpdateNag: true,
},
};
const systemDefaultsContent = {
general: {
disableUpdateNag: false,
},
};
vi.mocked(fs.existsSync).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath()) {
return JSON.stringify(systemSettingsContent);
}
if (p === getSystemDefaultsPath()) {
return JSON.stringify(systemDefaultsContent);
}
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Verify system settings were migrated
expect(settings.system.settings.general).toHaveProperty(
'enableAutoUpdateNotification',
);
expect(
(settings.system.settings.general as Record<string, unknown>)[
'enableAutoUpdateNotification'
],
).toBe(false);
// Verify system defaults settings were migrated
expect(settings.systemDefaults.settings.general).toHaveProperty(
'enableAutoUpdateNotification',
);
expect(
(settings.systemDefaults.settings.general as Record<string, unknown>)[
'enableAutoUpdateNotification'
],
).toBe(true);
// Merged should also reflect it (system overrides defaults, but both are migrated)
expect(settings.merged.general?.enableAutoUpdateNotification).toBe(false);
});
});
describe('saveSettings', () => {
-2
View File
@@ -808,8 +808,6 @@ export function migrateDeprecatedSettings(
processScope(SettingScope.User);
processScope(SettingScope.Workspace);
processScope(SettingScope.System);
processScope(SettingScope.SystemDefaults);
return anyModified;
}
@@ -395,8 +395,8 @@ describe('SettingsSchema', () => {
);
});
it('should have hooksConfig.notifications setting in schema', () => {
const setting = getSettingsSchema().hooksConfig?.properties.notifications;
it('should have hooks.notifications setting in schema', () => {
const setting = getSettingsSchema().hooks.properties.notifications;
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('Advanced');
+7 -15
View File
@@ -1631,9 +1631,9 @@ const SETTINGS_SCHEMA = {
},
},
hooksConfig: {
hooks: {
type: 'object',
label: 'HooksConfig',
label: 'Hooks',
category: 'Advanced',
requiresRestart: false,
default: {},
@@ -1646,7 +1646,7 @@ const SETTINGS_SCHEMA = {
label: 'Enable Hooks',
category: 'Advanced',
requiresRestart: false,
default: true,
default: false,
description:
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
showInDialog: false,
@@ -1675,18 +1675,6 @@ const SETTINGS_SCHEMA = {
description: 'Show visual indicators when hooks are executing.',
showInDialog: true,
},
},
},
hooks: {
type: 'object',
label: 'Hook Events',
category: 'Advanced',
requiresRestart: false,
default: {},
description: 'Event-specific hook configurations.',
showInDialog: false,
properties: {
BeforeTool: {
type: 'array',
label: 'Before Tool Hooks',
@@ -2129,6 +2117,10 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
type: 'boolean',
description: 'Whether to enable the agent.',
},
disabled: {
type: 'boolean',
description: 'Whether to disable the agent.',
},
},
},
CustomTheme: {
-2
View File
@@ -1085,8 +1085,6 @@ describe('gemini.tsx main function exit codes', () => {
vi.mocked(loadSandboxConfig).mockResolvedValue({} as any);
vi.mocked(loadCliConfig).mockResolvedValue({
refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')),
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
+2 -6
View File
@@ -373,7 +373,6 @@ export async function main() {
// Refresh auth to fetch remote admin settings from CCPA and before entering
// the sandbox because the sandbox will interfere with the Oauth2 web
// redirect.
let initialAuthFailed = false;
if (
settings.merged.security.auth.selectedType &&
!settings.merged.security.auth.useExternal
@@ -401,7 +400,8 @@ export async function main() {
}
} catch (err) {
debugLogger.error('Error authenticating:', err);
initialAuthFailed = true;
await runExitCleanup();
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
}
}
@@ -427,10 +427,6 @@ export async function main() {
// another way to decouple refreshAuth from requiring a config.
if (sandboxConfig) {
if (initialAuthFailed) {
await runExitCleanup();
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
}
let stdinData = '';
if (!process.stdin.isTTY) {
stdinData = await readStdin();
-1
View File
@@ -166,7 +166,6 @@ const mockUIActions: UIActions = {
handleFinalSubmit: vi.fn(),
handleClearScreen: vi.fn(),
handleProQuotaChoice: vi.fn(),
handleValidationChoice: vi.fn(),
setQueueErrorMessage: vi.fn(),
popAllMessages: vi.fn(),
handleApiKeySubmit: vi.fn(),
+1 -11
View File
@@ -495,12 +495,7 @@ export const AppContainer = (props: AppContainerProps) => {
}
}, [authState, authContext, setAuthState]);
const {
proQuotaRequest,
handleProQuotaChoice,
validationRequest,
handleValidationChoice,
} = useQuotaAndFallback({
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
config,
historyManager,
userTier,
@@ -1476,7 +1471,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
showPrivacyNotice ||
showIdeRestartPrompt ||
!!proQuotaRequest ||
!!validationRequest ||
isSessionBrowserOpen ||
isAuthDialogOpen ||
authState === AuthState.AwaitingApiKeyInput;
@@ -1594,7 +1588,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
currentModel,
userTier,
proQuotaRequest,
validationRequest,
contextFileNames,
errorCount,
availableTerminalHeight,
@@ -1685,7 +1678,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
showAutoAcceptIndicator,
userTier,
proQuotaRequest,
validationRequest,
contextFileNames,
errorCount,
availableTerminalHeight,
@@ -1755,7 +1747,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleFinalSubmit,
handleClearScreen,
handleProQuotaChoice,
handleValidationChoice,
openSessionBrowser,
closeSessionBrowser,
handleResumeSession,
@@ -1796,7 +1787,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleFinalSubmit,
handleClearScreen,
handleProQuotaChoice,
handleValidationChoice,
openSessionBrowser,
closeSessionBrowser,
handleResumeSession,
@@ -149,7 +149,7 @@ describe('agentsCommand', () => {
});
// Add agent to disabled overrides so validation passes
mockContext.services.settings.merged.agents.overrides['test-agent'] = {
enabled: false,
disabled: true,
};
vi.mocked(enableAgent).mockReturnValue({
@@ -264,7 +264,7 @@ describe('agentsCommand', () => {
it('should show info message if agent is already disabled', async () => {
mockConfig.getAgentRegistry().getAllAgentNames.mockReturnValue([]);
mockContext.services.settings.merged.agents.overrides['test-agent'] = {
enabled: false,
disabled: true,
};
const disableCommand = agentsCommand.subCommands?.find(
@@ -85,10 +85,10 @@ async function enableAction(
const allAgents = agentRegistry.getAllAgentNames();
const overrides = settings.merged.agents.overrides;
const disabledAgents = Object.keys(overrides).filter(
(name) => overrides[name]?.enabled === false,
(name) => overrides[name]?.disabled === true,
);
if (allAgents.includes(agentName) && !disabledAgents.includes(agentName)) {
if (allAgents.includes(agentName)) {
return {
type: 'message',
messageType: 'info',
@@ -96,7 +96,7 @@ async function enableAction(
};
}
if (!disabledAgents.includes(agentName) && !allAgents.includes(agentName)) {
if (!disabledAgents.includes(agentName)) {
return {
type: 'message',
messageType: 'error',
@@ -155,7 +155,7 @@ async function disableAction(
const allAgents = agentRegistry.getAllAgentNames();
const overrides = settings.merged.agents.overrides;
const disabledAgents = Object.keys(overrides).filter(
(name) => overrides[name]?.enabled === false,
(name) => overrides[name]?.disabled === true,
);
if (disabledAgents.includes(agentName)) {
@@ -206,7 +206,7 @@ function completeAgentsToEnable(context: CommandContext, partialArg: string) {
const overrides = settings.merged.agents.overrides;
const disabledAgents = Object.entries(overrides)
.filter(([_, override]) => override?.enabled === false)
.filter(([_, override]) => override?.disabled === true)
.map(([name]) => name);
return disabledAgents.filter((name) => name.startsWith(partialArg));
@@ -26,7 +26,7 @@ describe('hooksCommand', () => {
};
let mockSettings: {
merged: {
hooksConfig?: {
hooks?: {
disabled?: string[];
};
tools?: {
@@ -58,7 +58,7 @@ describe('hooksCommand', () => {
// Create mock settings
mockSettings = {
merged: {
hooksConfig: {
hooks: {
disabled: [],
},
},
@@ -273,7 +273,7 @@ describe('hooksCommand', () => {
it('should enable a hook and update settings', async () => {
// Update the context's settings with disabled hooks
mockContext.services.settings.merged.hooksConfig.disabled = [
mockContext.services.settings.merged.hooks.disabled = [
'test-hook',
'other-hook',
];
@@ -289,7 +289,7 @@ describe('hooksCommand', () => {
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
expect.any(String),
'hooksConfig.disabled',
'hooks.disabled',
['other-hook'],
);
expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith(
@@ -404,7 +404,7 @@ describe('hooksCommand', () => {
});
it('should disable a hook and update settings', async () => {
mockContext.services.settings.merged.hooksConfig.disabled = [];
mockContext.services.settings.merged.hooks.disabled = [];
const disableCmd = hooksCommand.subCommands!.find(
(cmd) => cmd.name === 'disable',
@@ -417,7 +417,7 @@ describe('hooksCommand', () => {
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
expect.any(String),
'hooksConfig.disabled',
'hooks.disabled',
['test-hook'],
);
expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith(
@@ -433,7 +433,7 @@ describe('hooksCommand', () => {
it('should synchronize with hook system even if hook is already in disabled list', async () => {
// Update the context's settings with the hook already disabled
mockContext.services.settings.merged.hooksConfig.disabled = ['test-hook'];
mockContext.services.settings.merged.hooks.disabled = ['test-hook'];
const disableCmd = hooksCommand.subCommands!.find(
(cmd) => cmd.name === 'disable',
@@ -458,7 +458,7 @@ describe('hooksCommand', () => {
});
it('should handle error when disabling hook fails', async () => {
mockContext.services.settings.merged.hooksConfig.disabled = [];
mockContext.services.settings.merged.hooks.disabled = [];
mockSettings.setValue.mockImplementationOnce(() => {
throw new Error('Failed to save settings');
});
@@ -637,7 +637,7 @@ describe('hooksCommand', () => {
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
expect.any(String),
'hooksConfig.disabled',
'hooks.disabled',
[],
);
expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith(
@@ -761,7 +761,7 @@ describe('hooksCommand', () => {
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
expect.any(String),
'hooksConfig.disabled',
'hooks.disabled',
['hook-1', 'hook-2', 'hook-3'],
);
expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith(
+10 -10
View File
@@ -76,7 +76,7 @@ async function enableAction(
// Get current disabled hooks from settings
const settings = context.services.settings;
const disabledHooks = settings.merged.hooksConfig.disabled;
const disabledHooks = settings.merged.hooks.disabled;
// Remove from disabled list if present
const newDisabledHooks = disabledHooks.filter(
(name: string) => name !== hookName,
@@ -87,10 +87,10 @@ async function enableAction(
const scope = settings.workspace
? SettingScope.Workspace
: SettingScope.User;
settings.setValue(scope, 'hooksConfig.disabled', newDisabledHooks);
settings.setValue(scope, 'hooks.disabled', newDisabledHooks);
// Update core config so re-initialization (e.g. extension reload) respects the change
config.updateDisabledHooks(settings.merged.hooksConfig.disabled);
config.updateDisabledHooks(settings.merged.hooks.disabled);
// Enable in hook system
hookSystem.setHookEnabled(hookName, true);
@@ -145,7 +145,7 @@ async function disableAction(
// Get current disabled hooks from settings
const settings = context.services.settings;
const disabledHooks = settings.merged.hooksConfig.disabled;
const disabledHooks = settings.merged.hooks.disabled;
// Add to disabled list if not already present
try {
if (!disabledHooks.includes(hookName)) {
@@ -154,11 +154,11 @@ async function disableAction(
const scope = settings.workspace
? SettingScope.Workspace
: SettingScope.User;
settings.setValue(scope, 'hooksConfig.disabled', newDisabledHooks);
settings.setValue(scope, 'hooks.disabled', newDisabledHooks);
}
// Update core config so re-initialization (e.g. extension reload) respects the change
config.updateDisabledHooks(settings.merged.hooksConfig.disabled);
config.updateDisabledHooks(settings.merged.hooks.disabled);
// Always disable in hook system to ensure in-memory state matches settings
hookSystem.setHookEnabled(hookName, false);
@@ -250,10 +250,10 @@ async function enableAllAction(
const scope = settings.workspace
? SettingScope.Workspace
: SettingScope.User;
settings.setValue(scope, 'hooksConfig.disabled', []);
settings.setValue(scope, 'hooks.disabled', []);
// Update core config so re-initialization (e.g. extension reload) respects the change
config.updateDisabledHooks(settings.merged.hooksConfig.disabled);
config.updateDisabledHooks(settings.merged.hooks.disabled);
for (const hook of disabledHooks) {
const hookName = getHookDisplayName(hook);
@@ -323,10 +323,10 @@ async function disableAllAction(
const scope = settings.workspace
? SettingScope.Workspace
: SettingScope.User;
settings.setValue(scope, 'hooksConfig.disabled', allHookNames);
settings.setValue(scope, 'hooks.disabled', allHookNames);
// Update core config so re-initialization (e.g. extension reload) respects the change
config.updateDisabledHooks(settings.merged.hooksConfig.disabled);
config.updateDisabledHooks(settings.merged.hooks.disabled);
for (const hook of enabledHooks) {
const hookName = getHookDisplayName(hook);
@@ -100,7 +100,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
showErrorDetails: false,
constrainHeight: false,
isInputActive: true,
buffer: { text: '' },
buffer: '',
inputWidth: 80,
suggestionsWidth: 40,
userMessages: [],
@@ -389,7 +389,6 @@ describe('Composer', () => {
it('shows escape prompt when showEscapePrompt is true', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
history: [{ id: 1, type: 'user', text: 'test' }],
});
const { lastFrame } = renderComposer(uiState);
@@ -6,7 +6,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { appEvents, AppEvent } from '../../utils/events.js';
import { coreEvents } from '@google/gemini-cli-core';
import {
profiler,
DebugProfiler,
@@ -17,7 +16,6 @@ import { render } from '../../test-utils/render.js';
import { useUIState, type UIState } from '../contexts/UIStateContext.js';
import { FixedDeque } from 'mnemonist';
import { debugState } from '../debug.js';
import { act } from 'react';
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(),
@@ -268,40 +266,4 @@ describe('DebugProfiler Component', () => {
expect(output).toContain('5 (idle)');
expect(output).toContain('2 (flicker)');
});
it('should report an action when a CoreEvent is emitted', async () => {
vi.mocked(useUIState).mockReturnValue({
showDebugProfiler: true,
constrainHeight: false,
} as unknown as UIState);
const reportActionSpy = vi.spyOn(profiler, 'reportAction');
const { unmount } = render(<DebugProfiler />);
act(() => {
coreEvents.emitModelChanged('new-model');
});
expect(reportActionSpy).toHaveBeenCalled();
unmount();
});
it('should report an action when an AppEvent is emitted', async () => {
vi.mocked(useUIState).mockReturnValue({
showDebugProfiler: true,
constrainHeight: false,
} as unknown as UIState);
const reportActionSpy = vi.spyOn(profiler, 'reportAction');
const { unmount } = render(<DebugProfiler />);
act(() => {
appEvents.emit(AppEvent.SelectionWarning);
});
expect(reportActionSpy).toHaveBeenCalled();
unmount();
});
});
@@ -11,7 +11,7 @@ import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { debugState } from '../debug.js';
import { appEvents, AppEvent } from '../../utils/events.js';
import { coreEvents, CoreEvent, debugLogger } from '@google/gemini-cli-core';
import { debugLogger } from '@google/gemini-cli-core';
// Frames that render at least this far before or after an action are considered
// idle frames.
@@ -160,29 +160,9 @@ export const DebugProfiler = () => {
stdin.on('data', handler);
stdout.on('resize', handler);
// Register handlers for all core and app events to ensure they are
// considered "actions" and don't trigger spurious idle frame warnings.
// These events are expected to trigger UI renders.
for (const eventName of Object.values(CoreEvent)) {
coreEvents.on(eventName, handler);
}
for (const eventName of Object.values(AppEvent)) {
appEvents.on(eventName, handler);
}
return () => {
stdin.off('data', handler);
stdout.off('resize', handler);
for (const eventName of Object.values(CoreEvent)) {
coreEvents.off(eventName, handler);
}
for (const eventName of Object.values(AppEvent)) {
appEvents.off(eventName, handler);
}
profiler.profilersActive--;
};
}, []);
@@ -17,7 +17,6 @@ import { ApiAuthDialog } from '../auth/ApiAuthDialog.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
import { ProQuotaDialog } from './ProQuotaDialog.js';
import { ValidationDialog } from './ValidationDialog.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
import { SessionBrowser } from './SessionBrowser.js';
@@ -69,16 +68,6 @@ export const DialogManager = ({
/>
);
}
if (uiState.validationRequest) {
return (
<ValidationDialog
validationLink={uiState.validationRequest.validationLink}
validationDescription={uiState.validationRequest.validationDescription}
learnMoreUrl={uiState.validationRequest.learnMoreUrl}
onChoice={uiActions.handleValidationChoice}
/>
);
}
if (uiState.shouldShowIdePrompt) {
return (
<IdeIntegrationNudge
@@ -1893,19 +1893,13 @@ describe('InputPrompt', () => {
unmount();
});
it('should submit /rewind on double ESC when buffer is empty', async () => {
it('should submit /rewind on double ESC', async () => {
const onEscapePromptChange = vi.fn();
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('');
vi.mocked(props.buffer.setText).mockClear();
props.buffer.setText('some text');
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiState: {
history: [{ id: 1, type: 'user', text: 'test' }],
},
},
);
await act(async () => {
@@ -1917,26 +1911,6 @@ describe('InputPrompt', () => {
unmount();
});
it('should clear the buffer on esc esc if it has text', async () => {
const onEscapePromptChange = vi.fn();
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('some text');
vi.mocked(props.buffer.setText).mockClear();
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await act(async () => {
stdin.write('\x1B\x1B');
vi.advanceTimersByTime(100);
expect(props.buffer.setText).toHaveBeenCalledWith('');
expect(props.onSubmit).not.toHaveBeenCalledWith('/rewind');
});
unmount();
});
it('should reset escape state on any non-ESC key', async () => {
const onEscapePromptChange = vi.fn();
props.onEscapePromptChange = onEscapePromptChange;
+4 -12
View File
@@ -138,7 +138,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState();
const { setEmbeddedShellFocused } = useUIActions();
const { mainAreaWidth, activePtyId, history } = useUIState();
const { mainAreaWidth, activePtyId } = useUIState();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const escPressCount = useRef(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@@ -495,7 +495,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// Handle double ESC
// Handle double ESC for rewind
if (escPressCount.current === 0) {
escPressCount.current = 1;
setShowEscapePrompt(true);
@@ -506,16 +506,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetEscapeState();
}, 500);
} else {
// Second ESC
// Second ESC triggers rewind
resetEscapeState();
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
} else {
if (history.length > 0) {
onSubmit('/rewind');
}
}
onSubmit('/rewind');
}
return;
}
@@ -887,7 +880,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onSubmit,
activePtyId,
setEmbeddedShellFocused,
history,
],
);
@@ -11,7 +11,6 @@ import { StatusDisplay } from './StatusDisplay.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import type { TextBuffer } from './shared/text-buffer.js';
// Mock child components to simplify testing
vi.mock('./ContextSummaryDisplay.js', () => ({
@@ -24,13 +23,8 @@ vi.mock('./HookStatusDisplay.js', () => ({
HookStatusDisplay: () => <Text>Mock Hook Status Display</Text>,
}));
// Use a type that allows partial buffer for mocking purposes
type UIStateOverrides = Partial<Omit<UIState, 'buffer'>> & {
buffer?: Partial<TextBuffer>;
};
// Create mock context providers
const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
({
ctrlCPressedOnce: false,
warningMessage: null,
@@ -41,8 +35,6 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
ideContextState: null,
geminiMdFileCount: 0,
contextFileNames: [],
buffer: { text: '' },
history: [{ id: 1, type: 'user', text: 'test' }],
...overrides,
}) as UIState;
@@ -60,7 +52,7 @@ const createMockConfig = (overrides = {}) => ({
const createMockSettings = (merged = {}) => ({
merged: {
hooksConfig: { notifications: true },
hooks: { notifications: true },
ui: { hideContextSummary: false },
...merged,
},
@@ -155,22 +147,9 @@ describe('StatusDisplay', () => {
expect(lastFrame()).toMatchSnapshot();
});
it('renders Escape prompt when buffer is empty', () => {
it('renders Escape prompt', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
buffer: { text: '' },
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders Escape prompt when buffer is NOT empty', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
buffer: { text: 'some text' },
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
@@ -206,7 +185,7 @@ describe('StatusDisplay', () => {
activeHooks: [{ name: 'hook', eventName: 'event' }],
});
const settings = createMockSettings({
hooksConfig: { notifications: false },
hooks: { notifications: false },
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
@@ -45,28 +45,14 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
}
if (uiState.showEscapePrompt) {
const isPromptEmpty = uiState.buffer.text.length === 0;
const hasHistory = uiState.history.length > 0;
if (isPromptEmpty && !hasHistory) {
return null;
}
return (
<Text color={theme.text.secondary}>
Press Esc again to {isPromptEmpty ? 'rewind' : 'clear prompt'}.
</Text>
);
return <Text color={theme.text.secondary}>Press Esc again to rewind.</Text>;
}
if (uiState.queueErrorMessage) {
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
}
if (
uiState.activeHooks.length > 0 &&
settings.merged.hooksConfig.notifications
) {
if (uiState.activeHooks.length > 0 && settings.merged.hooks.notifications) {
return <HookStatusDisplay activeHooks={uiState.activeHooks} />;
}
@@ -1,195 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { act } from 'react';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { ValidationDialog } from './ValidationDialog.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
// Mock the child components and utilities
vi.mock('./shared/RadioButtonSelect.js', () => ({
RadioButtonSelect: vi.fn(),
}));
vi.mock('./CliSpinner.js', () => ({
CliSpinner: vi.fn(() => null),
}));
const mockOpenBrowserSecurely = vi.fn();
const mockShouldLaunchBrowser = vi.fn();
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
openBrowserSecurely: (...args: unknown[]) =>
mockOpenBrowserSecurely(...args),
shouldLaunchBrowser: () => mockShouldLaunchBrowser(),
};
});
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
describe('ValidationDialog', () => {
const mockOnChoice = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockShouldLaunchBrowser.mockReturnValue(true);
mockOpenBrowserSecurely.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('initial render (choosing state)', () => {
it('should render the main message and two options', () => {
const { lastFrame, unmount } = render(
<ValidationDialog onChoice={mockOnChoice} />,
);
expect(lastFrame()).toContain(
'Further action is required to use this service.',
);
expect(RadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
items: [
{
label: 'Verify your account',
value: 'verify',
key: 'verify',
},
{
label: 'Change authentication',
value: 'change_auth',
key: 'change_auth',
},
],
}),
undefined,
);
unmount();
});
it('should render learn more URL when provided', () => {
const { lastFrame, unmount } = render(
<ValidationDialog
learnMoreUrl="https://example.com/help"
onChoice={mockOnChoice}
/>,
);
expect(lastFrame()).toContain('Learn more:');
expect(lastFrame()).toContain('https://example.com/help');
unmount();
});
});
describe('onChoice handling', () => {
it('should call onChoice with change_auth when that option is selected', () => {
const { unmount } = render(<ValidationDialog onChoice={mockOnChoice} />);
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
act(() => {
onSelect('change_auth');
});
expect(mockOnChoice).toHaveBeenCalledWith('change_auth');
unmount();
});
it('should call onChoice with verify when no validation link is provided', () => {
const { unmount } = render(<ValidationDialog onChoice={mockOnChoice} />);
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
act(() => {
onSelect('verify');
});
expect(mockOnChoice).toHaveBeenCalledWith('verify');
unmount();
});
it('should open browser and transition to waiting state when verify is selected with a link', async () => {
const { lastFrame, unmount } = render(
<ValidationDialog
validationLink="https://accounts.google.com/verify"
onChoice={mockOnChoice}
/>,
);
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
await act(async () => {
await onSelect('verify');
});
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(
'https://accounts.google.com/verify',
);
expect(lastFrame()).toContain('Waiting for verification...');
unmount();
});
});
describe('headless mode', () => {
it('should show URL in message when browser cannot be launched', async () => {
mockShouldLaunchBrowser.mockReturnValue(false);
const { lastFrame, unmount } = render(
<ValidationDialog
validationLink="https://accounts.google.com/verify"
onChoice={mockOnChoice}
/>,
);
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
await act(async () => {
await onSelect('verify');
});
expect(mockOpenBrowserSecurely).not.toHaveBeenCalled();
expect(lastFrame()).toContain('Please open this URL in a browser:');
expect(lastFrame()).toContain('https://accounts.google.com/verify');
unmount();
});
});
describe('error state', () => {
it('should show error and options when browser fails to open', async () => {
mockOpenBrowserSecurely.mockRejectedValue(new Error('Browser not found'));
const { lastFrame, unmount } = render(
<ValidationDialog
validationLink="https://accounts.google.com/verify"
onChoice={mockOnChoice}
/>,
);
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
await act(async () => {
await onSelect('verify');
});
expect(lastFrame()).toContain('Browser not found');
// RadioButtonSelect should be rendered again with options in error state
expect((RadioButtonSelect as Mock).mock.calls.length).toBeGreaterThan(1);
unmount();
});
});
});
@@ -1,177 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useEffect, useCallback } from 'react';
import { Box, Text } from 'ink';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { theme } from '../semantic-colors.js';
import { CliSpinner } from './CliSpinner.js';
import {
openBrowserSecurely,
shouldLaunchBrowser,
type ValidationIntent,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
interface ValidationDialogProps {
validationLink?: string;
validationDescription?: string;
learnMoreUrl?: string;
onChoice: (choice: ValidationIntent) => void;
}
type DialogState = 'choosing' | 'waiting' | 'complete' | 'error';
export function ValidationDialog({
validationLink,
learnMoreUrl,
onChoice,
}: ValidationDialogProps): React.JSX.Element {
const [state, setState] = useState<DialogState>('choosing');
const [errorMessage, setErrorMessage] = useState<string>('');
const items = [
{
label: 'Verify your account',
value: 'verify' as const,
key: 'verify',
},
{
label: 'Change authentication',
value: 'change_auth' as const,
key: 'change_auth',
},
];
// Handle keypresses during 'waiting' state (ESC to cancel, Enter to confirm completion)
useKeypress(
(key) => {
if (keyMatchers[Command.ESCAPE](key) || keyMatchers[Command.QUIT](key)) {
onChoice('cancel');
} else if (keyMatchers[Command.RETURN](key)) {
// User confirmed verification is complete - transition to 'complete' state
setState('complete');
}
},
{ isActive: state === 'waiting' },
);
// When state becomes 'complete', show success message briefly then proceed
useEffect(() => {
if (state === 'complete') {
const timer = setTimeout(() => {
onChoice('verify');
}, 500);
return () => clearTimeout(timer);
}
return undefined;
}, [state, onChoice]);
const handleSelect = useCallback(
async (choice: ValidationIntent) => {
if (choice === 'verify') {
if (validationLink) {
// Check if we're in an environment where we can launch a browser
if (!shouldLaunchBrowser()) {
// In headless mode, show the link and wait for user to manually verify
setErrorMessage(
`Please open this URL in a browser: ${validationLink}`,
);
setState('waiting');
return;
}
try {
await openBrowserSecurely(validationLink);
setState('waiting');
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : 'Failed to open browser',
);
setState('error');
}
} else {
// No validation link, just retry
onChoice('verify');
}
} else {
// 'change_auth' or 'cancel'
onChoice(choice);
}
},
[validationLink, onChoice],
);
if (state === 'error') {
return (
<Box borderStyle="round" flexDirection="column" padding={1}>
<Text color={theme.status.error}>
{errorMessage ||
'Failed to open verification link. Please try again or change authentication.'}
</Text>
<Box marginTop={1}>
<RadioButtonSelect
items={items}
onSelect={(choice) => void handleSelect(choice as ValidationIntent)}
/>
</Box>
</Box>
);
}
if (state === 'waiting') {
return (
<Box borderStyle="round" flexDirection="column" padding={1}>
<Box>
<CliSpinner />
<Text>
{' '}
Waiting for verification... (Press ESC or CTRL+C to cancel)
</Text>
</Box>
{errorMessage && (
<Box marginTop={1}>
<Text>{errorMessage}</Text>
</Box>
)}
<Box marginTop={1}>
<Text dimColor>Press Enter when verification is complete.</Text>
</Box>
</Box>
);
}
if (state === 'complete') {
return (
<Box borderStyle="round" flexDirection="column" padding={1}>
<Text color={theme.status.success}>Verification complete</Text>
</Box>
);
}
return (
<Box borderStyle="round" flexDirection="column" padding={1}>
<Box marginBottom={1}>
<Text>Further action is required to use this service.</Text>
</Box>
<Box marginTop={1} marginBottom={1}>
<RadioButtonSelect
items={items}
onSelect={(choice) => void handleSelect(choice as ValidationIntent)}
/>
</Box>
{learnMoreUrl && (
<Box marginTop={1}>
<Text dimColor>
Learn more: <Text color={theme.text.accent}>{learnMoreUrl}</Text>
</Text>
</Box>
)}
</Box>
);
}
@@ -10,9 +10,7 @@ exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock C
exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`;
exports[`StatusDisplay > renders Escape prompt when buffer is NOT empty 1`] = `"Press Esc again to clear prompt."`;
exports[`StatusDisplay > renders Escape prompt when buffer is empty 1`] = `"Press Esc again to rewind."`;
exports[`StatusDisplay > renders Escape prompt 1`] = `"Press Esc again to rewind."`;
exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = `"Mock Hook Status Display"`;
@@ -356,18 +356,18 @@ describe('ScrollableList Demo Behavior', () => {
expect(listRef?.getScrollState()?.scrollTop).toBeLessThan(2);
});
// End -> \x1b[1;5F (Ctrl+End)
// End -> \x1b[F
await act(async () => {
stdin.write('\x1b[1;5F');
stdin.write('\x1b[F');
});
await waitFor(() => {
// Total 50 items, height 10. Max scroll ~40.
expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThan(30);
});
// Home -> \x1b[1;5H (Ctrl+Home)
// Home -> \x1b[H
await act(async () => {
stdin.write('\x1b[1;5H');
stdin.write('\x1b[H');
});
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(0);
@@ -1096,23 +1096,6 @@ describe('useTextBuffer', () => {
expect(getBufferState(result).lines).toEqual(['', '']);
});
it('should handle Ctrl+J as newline', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
act(() =>
result.current.handleInput({
name: 'j',
ctrl: true,
meta: false,
shift: false,
insertable: false,
sequence: '\n',
}),
);
expect(getBufferState(result).lines).toEqual(['', '']);
});
it('should do nothing for a tab key press', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
@@ -2242,9 +2242,11 @@ export function useTextBuffer({
if (!command) {
command =
process.env['VISUAL'] ??
(process.env['VISUAL'] ??
process.env['EDITOR'] ??
(process.platform === 'win32' ? 'notepad' : 'vi');
process.platform === 'win32')
? 'notepad'
: 'vi';
}
dispatch({ type: 'create_undo_snapshot' });
@@ -2290,7 +2292,6 @@ export function useTextBuffer({
if (key.name === 'paste') insert(input, { paste: true });
else if (keyMatchers[Command.RETURN](key)) newline();
else if (keyMatchers[Command.NEWLINE](key)) newline();
else if (keyMatchers[Command.MOVE_LEFT](key)) move('left');
else if (keyMatchers[Command.MOVE_RIGHT](key)) move('right');
else if (keyMatchers[Command.MOVE_UP](key)) move('up');
@@ -154,36 +154,6 @@ describe('KeypressContext', () => {
);
},
);
it('should recognize \n (LF) as ctrl+j', async () => {
const { keyHandler } = setupKeypressTest();
act(() => stdin.write('\n'));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'j',
ctrl: true,
meta: false,
shift: false,
}),
);
});
it('should recognize \\x1b\\n as Alt+Enter (return with meta)', async () => {
const { keyHandler } = setupKeypressTest();
act(() => stdin.write('\x1b\n'));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'return',
ctrl: false,
meta: true,
shift: false,
}),
);
});
});
describe('Fast return buffering', () => {
@@ -524,9 +524,9 @@ function* emitKeys(
// carriage return
name = 'return';
meta = escaped;
} else if (escaped && ch === '\n') {
// Alt+Enter (linefeed), should be consistent with carriage return
name = 'return';
} else if (ch === '\n') {
// Enter, should have been called linefeed
name = 'enter';
meta = escaped;
} else if (ch === '\t') {
// tab
@@ -46,7 +46,6 @@ export interface UIActions {
handleProQuotaChoice: (
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
) => void;
handleValidationChoice: (choice: 'verify' | 'change_auth' | 'cancel') => void;
openSessionBrowser: () => void;
closeSessionBrowser: () => void;
handleResumeSession: (session: SessionInfo) => Promise<void>;
@@ -23,7 +23,6 @@ import type {
UserTierId,
IdeInfo,
FallbackIntent,
ValidationIntent,
} from '@google/gemini-cli-core';
import type { DOMElement } from 'ink';
import type { SessionStatsState } from '../contexts/SessionContext.js';
@@ -39,13 +38,6 @@ export interface ProQuotaDialogRequest {
resolve: (intent: FallbackIntent) => void;
}
export interface ValidationDialogRequest {
validationLink?: string;
validationDescription?: string;
learnMoreUrl?: string;
resolve: (intent: ValidationIntent) => void;
}
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
@@ -110,7 +102,6 @@ export interface UIState {
// Quota-related state
userTier: UserTierId | undefined;
proQuotaRequest: ProQuotaDialogRequest | null;
validationRequest: ValidationDialogRequest | null;
currentModel: string;
contextFileNames: string[];
errorCount: number;
@@ -28,7 +28,6 @@ import {
processRestorableToolCalls,
recordToolCallInteractions,
ToolErrorType,
ValidationRequiredError,
coreEvents,
CoreEvent,
MCPDiscoveryState,
@@ -1101,12 +1100,6 @@ export const useGeminiStream = (
spanMetadata.error = error;
if (error instanceof UnauthorizedError) {
onAuthError('Session expired or is unauthorized.');
} else if (
// Suppress ValidationRequiredError if it was marked as handled (e.g. user clicked change_auth or cancelled)
error instanceof ValidationRequiredError &&
error.userHandled
) {
// Error was handled by validation dialog, don't display again
} else if (!isNodeError(error) || error.name !== 'AbortError') {
addItem(
{
@@ -498,186 +498,4 @@ To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
);
});
});
describe('Validation Handler', () => {
let setValidationHandlerSpy: SpyInstance;
beforeEach(() => {
setValidationHandlerSpy = vi.spyOn(mockConfig, 'setValidationHandler');
});
it('should register a validation handler on initialization', () => {
renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
expect(setValidationHandlerSpy).toHaveBeenCalledTimes(1);
expect(setValidationHandlerSpy.mock.calls[0][0]).toBeInstanceOf(Function);
});
it('should set a validation request when handler is called', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
const handler = setValidationHandlerSpy.mock.calls[0][0] as (
validationLink?: string,
validationDescription?: string,
learnMoreUrl?: string,
) => Promise<'verify' | 'change_auth' | 'cancel'>;
let promise: Promise<'verify' | 'change_auth' | 'cancel'>;
act(() => {
promise = handler(
'https://example.com/verify',
'Please verify',
'https://example.com/help',
);
});
const request = result.current.validationRequest;
expect(request).not.toBeNull();
expect(request?.validationLink).toBe('https://example.com/verify');
expect(request?.validationDescription).toBe('Please verify');
expect(request?.learnMoreUrl).toBe('https://example.com/help');
// Simulate user choosing verify
act(() => {
result.current.handleValidationChoice('verify');
});
const intent = await promise!;
expect(intent).toBe('verify');
expect(result.current.validationRequest).toBeNull();
});
it('should handle race conditions by returning cancel for subsequent requests', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
const handler = setValidationHandlerSpy.mock.calls[0][0] as (
validationLink?: string,
) => Promise<'verify' | 'change_auth' | 'cancel'>;
let promise1: Promise<'verify' | 'change_auth' | 'cancel'>;
act(() => {
promise1 = handler('https://example.com/verify1');
});
const firstRequest = result.current.validationRequest;
expect(firstRequest).not.toBeNull();
let result2: 'verify' | 'change_auth' | 'cancel';
await act(async () => {
result2 = await handler('https://example.com/verify2');
});
// The lock should have stopped the second request
expect(result2!).toBe('cancel');
expect(result.current.validationRequest).toBe(firstRequest);
// Complete the first request
act(() => {
result.current.handleValidationChoice('verify');
});
const intent1 = await promise1!;
expect(intent1).toBe('verify');
expect(result.current.validationRequest).toBeNull();
});
it('should add info message when change_auth is chosen', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
const handler = setValidationHandlerSpy.mock.calls[0][0] as (
validationLink?: string,
) => Promise<'verify' | 'change_auth' | 'cancel'>;
let promise: Promise<'verify' | 'change_auth' | 'cancel'>;
act(() => {
promise = handler('https://example.com/verify');
});
act(() => {
result.current.handleValidationChoice('change_auth');
});
const intent = await promise!;
expect(intent).toBe('change_auth');
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0];
expect(lastCall.type).toBe(MessageType.INFO);
expect(lastCall.text).toBe('Use /auth to change authentication method.');
});
it('should not add info message when cancel is chosen', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
const handler = setValidationHandlerSpy.mock.calls[0][0] as (
validationLink?: string,
) => Promise<'verify' | 'change_auth' | 'cancel'>;
let promise: Promise<'verify' | 'change_auth' | 'cancel'>;
act(() => {
promise = handler('https://example.com/verify');
});
act(() => {
result.current.handleValidationChoice('cancel');
});
const intent = await promise!;
expect(intent).toBe('cancel');
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
});
it('should do nothing if handleValidationChoice is called without pending request', () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
act(() => {
result.current.handleValidationChoice('verify');
});
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
});
});
});
@@ -9,8 +9,6 @@ import {
type Config,
type FallbackModelHandler,
type FallbackIntent,
type ValidationHandler,
type ValidationIntent,
TerminalQuotaError,
ModelNotFoundError,
type UserTierId,
@@ -21,10 +19,7 @@ import {
import { useCallback, useEffect, useRef, useState } from 'react';
import { type UseHistoryManagerReturn } from './useHistoryManager.js';
import { MessageType } from '../types.js';
import {
type ProQuotaDialogRequest,
type ValidationDialogRequest,
} from '../contexts/UIStateContext.js';
import { type ProQuotaDialogRequest } from '../contexts/UIStateContext.js';
interface UseQuotaAndFallbackArgs {
config: Config;
@@ -41,10 +36,7 @@ export function useQuotaAndFallback({
}: UseQuotaAndFallbackArgs) {
const [proQuotaRequest, setProQuotaRequest] =
useState<ProQuotaDialogRequest | null>(null);
const [validationRequest, setValidationRequest] =
useState<ValidationDialogRequest | null>(null);
const isDialogPending = useRef(false);
const isValidationPending = useRef(false);
// Set up Flash fallback handler
useEffect(() => {
@@ -128,36 +120,6 @@ export function useQuotaAndFallback({
config.setFallbackModelHandler(fallbackHandler);
}, [config, historyManager, userTier, setModelSwitchedFromQuotaError]);
// Set up validation handler for 403 VALIDATION_REQUIRED errors
useEffect(() => {
const validationHandler: ValidationHandler = async (
validationLink,
validationDescription,
learnMoreUrl,
): Promise<ValidationIntent> => {
if (isValidationPending.current) {
return 'cancel'; // A validation dialog is already active
}
isValidationPending.current = true;
const intent: ValidationIntent = await new Promise<ValidationIntent>(
(resolve) => {
// Call setValidationRequest directly - same pattern as proQuotaRequest
setValidationRequest({
validationLink,
validationDescription,
learnMoreUrl,
resolve,
});
},
);
return intent;
};
config.setValidationHandler(validationHandler);
}, [config]);
const handleProQuotaChoice = useCallback(
(choice: FallbackIntent) => {
if (!proQuotaRequest) return;
@@ -186,35 +148,9 @@ export function useQuotaAndFallback({
[proQuotaRequest, historyManager, config, setModelSwitchedFromQuotaError],
);
const handleValidationChoice = useCallback(
(choice: ValidationIntent) => {
// Guard against double-execution (e.g. rapid clicks) and stale requests
if (!isValidationPending.current || !validationRequest) return;
// Immediately clear the flag to prevent any subsequent calls from passing the guard
isValidationPending.current = false;
validationRequest.resolve(choice);
setValidationRequest(null);
if (choice === 'change_auth') {
historyManager.addItem(
{
type: MessageType.INFO,
text: 'Use /auth to change authentication method.',
},
Date.now(),
);
}
},
[validationRequest, historyManager],
);
return {
proQuotaRequest,
handleProQuotaChoice,
validationRequest,
handleValidationChoice,
};
}
+4 -6
View File
@@ -43,7 +43,6 @@ describe('keyMatchers', () => {
createKey('a'),
createKey('a', { shift: true }),
createKey('b', { ctrl: true }),
createKey('home', { ctrl: true }),
],
},
{
@@ -53,7 +52,6 @@ describe('keyMatchers', () => {
createKey('e'),
createKey('e', { shift: true }),
createKey('a', { ctrl: true }),
createKey('end', { ctrl: true }),
],
},
{
@@ -159,13 +157,13 @@ describe('keyMatchers', () => {
},
{
command: Command.SCROLL_HOME,
positive: [createKey('home', { ctrl: true })],
negative: [createKey('end'), createKey('home')],
positive: [createKey('home')],
negative: [createKey('end')],
},
{
command: Command.SCROLL_END,
positive: [createKey('end', { ctrl: true })],
negative: [createKey('home'), createKey('end')],
positive: [createKey('end')],
negative: [createKey('home')],
},
{
command: Command.PAGE_UP,
+12 -11
View File
@@ -29,8 +29,8 @@ export interface AgentActionResult {
}
/**
* Enables an agent by ensuring it is enabled in any writable scope (User and Workspace).
* It sets `agents.overrides.<agentName>.enabled` to `true`.
* Enables an agent by ensuring it is not disabled in any writable scope (User and Workspace).
* It sets `agents.overrides.<agentName>.disabled` to `false` if it was found to be `true`.
*/
export function enableAgent(
settings: LoadedSettings,
@@ -45,9 +45,9 @@ export function enableAgent(
const scopePath = settings.forScope(scope).path;
const agentOverrides =
settings.forScope(scope).settings.agents?.overrides;
const isEnabled = agentOverrides?.[agentName]?.enabled === true;
const isDisabled = agentOverrides?.[agentName]?.disabled === true;
if (!isEnabled) {
if (isDisabled) {
foundInDisabledScopes.push({ scope, path: scopePath });
} else {
alreadyEnabledScopes.push({ scope, path: scopePath });
@@ -68,8 +68,9 @@ export function enableAgent(
const modifiedScopes: ModifiedScope[] = [];
for (const { scope, path } of foundInDisabledScopes) {
if (isLoadableSettingScope(scope)) {
// Explicitly enable it.
settings.setValue(scope, `agents.overrides.${agentName}.enabled`, true);
// Explicitly enable it to override any lower-precedence disables, or just clear the disable.
// Setting to false ensures it is enabled.
settings.setValue(scope, `agents.overrides.${agentName}.disabled`, false);
modifiedScopes.push({ scope, path });
}
}
@@ -84,7 +85,7 @@ export function enableAgent(
}
/**
* Disables an agent by setting `agents.overrides.<agentName>.enabled` to `false` in the specified scope.
* Disables an agent by setting `agents.overrides.<agentName>.disabled` to `true` in the specified scope.
*/
export function disableAgent(
settings: LoadedSettings,
@@ -104,9 +105,9 @@ export function disableAgent(
const scopePath = settings.forScope(scope).path;
const agentOverrides = settings.forScope(scope).settings.agents?.overrides;
const isEnabled = agentOverrides?.[agentName]?.enabled !== false;
const isDisabled = agentOverrides?.[agentName]?.disabled === true;
if (!isEnabled) {
if (isDisabled) {
return {
status: 'no-op',
agentName,
@@ -126,7 +127,7 @@ export function disableAgent(
if (isLoadableSettingScope(otherScope)) {
const otherOverrides =
settings.forScope(otherScope).settings.agents?.overrides;
if (otherOverrides?.[agentName]?.enabled === false) {
if (otherOverrides?.[agentName]?.disabled === true) {
alreadyDisabledInOther.push({
scope: otherScope,
path: settings.forScope(otherScope).path,
@@ -134,7 +135,7 @@ export function disableAgent(
}
}
settings.setValue(scope, `agents.overrides.${agentName}.enabled`, false);
settings.setValue(scope, `agents.overrides.${agentName}.disabled`, true);
return {
status: 'success',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.26.0-preview.2",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
+3 -3
View File
@@ -309,7 +309,7 @@ describe('AgentRegistry', () => {
const config = makeMockedConfig({
agents: {
overrides: {
generalist: { enabled: false },
generalist: { enabled: true, disabled: true },
},
},
});
@@ -704,7 +704,7 @@ describe('AgentRegistry', () => {
const config = makeMockedConfig({
agents: {
overrides: {
MockAgent: { enabled: false },
MockAgent: { disabled: true },
},
},
});
@@ -719,7 +719,7 @@ describe('AgentRegistry', () => {
const config = makeMockedConfig({
agents: {
overrides: {
RemoteAgent: { enabled: false },
RemoteAgent: { disabled: true },
},
},
});
+8 -4
View File
@@ -152,7 +152,7 @@ export class AgentRegistry {
// Only register the agent if it's enabled in the settings and not explicitly disabled via overrides.
if (
investigatorSettings?.enabled &&
agentsOverrides[CodebaseInvestigatorAgent.name]?.enabled !== false
!agentsOverrides[CodebaseInvestigatorAgent.name]?.disabled
) {
let model;
const settingsModel = investigatorSettings.model;
@@ -200,7 +200,7 @@ export class AgentRegistry {
// Register the CLI help agent if it's explicitly enabled and not explicitly disabled via overrides.
if (
cliHelpSettings.enabled &&
agentsOverrides[CliHelpAgent.name]?.enabled !== false
!agentsOverrides[CliHelpAgent.name]?.disabled
) {
this.registerLocalAgent(CliHelpAgent(this.config));
}
@@ -280,8 +280,12 @@ export class AgentRegistry {
const isExperimental = definition.experimental === true;
let isEnabled = !isExperimental;
if (overrides && overrides.enabled !== undefined) {
isEnabled = overrides.enabled;
if (overrides) {
if (overrides.disabled !== undefined) {
isEnabled = !overrides.disabled;
} else if (overrides.enabled !== undefined) {
isEnabled = overrides.enabled;
}
}
return isEnabled;
+1 -1
View File
@@ -1841,7 +1841,7 @@ describe('Hooks configuration', () => {
debugMode: false,
model: 'test-model',
cwd: '.',
disabledHooks: ['initial-hook'],
hooks: { disabled: ['initial-hook'] },
};
it('updateDisabledHooks should update the disabled list', () => {
+16 -24
View File
@@ -67,10 +67,7 @@ import {
ApprovalModeSwitchEvent,
ApprovalModeDurationEvent,
} from '../telemetry/types.js';
import type {
FallbackModelHandler,
ValidationHandler,
} from '../fallback/types.js';
import type { FallbackModelHandler } from '../fallback/types.js';
import { ModelAvailabilityService } from '../availability/modelAvailabilityService.js';
import { ModelRouterService } from '../routing/modelRouterService.js';
import { OutputFormat } from '../output/types.js';
@@ -180,6 +177,7 @@ export interface AgentRunConfig {
export interface AgentOverride {
modelConfig?: ModelConfig;
runConfig?: AgentRunConfig;
disabled?: boolean;
enabled?: boolean;
}
@@ -293,7 +291,6 @@ export interface SandboxConfig {
export interface ConfigParameters {
sessionId: string;
clientVersion?: string;
embeddingModel?: string;
sandbox?: SandboxConfig;
targetDir: string;
@@ -382,9 +379,10 @@ export interface ConfigParameters {
enableHooks?: boolean;
enableHooksUI?: boolean;
experiments?: Experiments;
hooks?: { [K in HookEventName]?: HookDefinition[] };
disabledHooks?: string[];
projectHooks?: { [K in HookEventName]?: HookDefinition[] };
hooks?: { [K in HookEventName]?: HookDefinition[] } & { disabled?: string[] };
projectHooks?: { [K in HookEventName]?: HookDefinition[] } & {
disabled?: string[];
};
previewFeatures?: boolean;
enableAgents?: boolean;
enableEventDrivenScheduler?: boolean;
@@ -418,7 +416,6 @@ export class Config {
private agentRegistry!: AgentRegistry;
private skillManager!: SkillManager;
private sessionId: string;
private clientVersion: string;
private fileSystemService: FileSystemService;
private contentGeneratorConfig!: ContentGeneratorConfig;
private contentGenerator!: ContentGenerator;
@@ -480,7 +477,6 @@ export class Config {
private readonly _enabledExtensions: string[];
private readonly enableExtensionReloading: boolean;
fallbackModelHandler?: FallbackModelHandler;
validationHandler?: ValidationHandler;
private quotaErrorOccurred: boolean = false;
private readonly summarizeToolOutput:
| Record<string, SummarizeToolOutputSettings>
@@ -558,7 +554,6 @@ export class Config {
constructor(params: ConfigParameters) {
this.sessionId = params.sessionId;
this.clientVersion = params.clientVersion ?? 'unknown';
this.embeddingModel =
params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL;
this.fileSystemService = new StandardFileSystemService();
@@ -682,8 +677,11 @@ export class Config {
? false
: (params.useWriteTodos ?? true);
this.enableHooksUI = params.enableHooksUI ?? true;
this.enableHooks = params.enableHooks ?? true;
this.disabledHooks = params.disabledHooks ?? [];
this.enableHooks = params.enableHooks ?? false;
this.disabledHooks =
(params.hooks && 'disabled' in params.hooks
? params.hooks.disabled
: undefined) ?? [];
this.codebaseInvestigatorSettings = {
enabled: params.codebaseInvestigatorSettings?.enabled ?? true,
@@ -724,7 +722,8 @@ export class Config {
this.disableYoloMode = params.disableYoloMode ?? false;
if (params.hooks) {
this.hooks = params.hooks;
const { disabled: _, ...restOfHooks } = params.hooks;
this.hooks = restOfHooks;
}
if (params.projectHooks) {
this.projectHooks = params.projectHooks;
@@ -812,7 +811,6 @@ export class Config {
this.toolRegistry = await this.createToolRegistry();
discoverToolsHandle?.end();
this.mcpClientManager = new McpClientManager(
this.clientVersion,
this.toolRegistry,
this,
this.eventEmitter,
@@ -1070,14 +1068,6 @@ export class Config {
return this.fallbackModelHandler;
}
setValidationHandler(handler: ValidationHandler): void {
this.validationHandler = handler;
}
getValidationHandler(): ValidationHandler | undefined {
return this.validationHandler;
}
resetTurn(): void {
this.modelAvailabilityService.resetTurn();
}
@@ -2000,7 +1990,9 @@ export class Config {
/**
* Get project-specific hooks configuration
*/
getProjectHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined {
getProjectHooks():
| ({ [K in HookEventName]?: HookDefinition[] } & { disabled?: string[] })
| undefined {
return this.projectHooks;
}
-1
View File
@@ -51,7 +51,6 @@ export function resolveModel(
case DEFAULT_GEMINI_MODEL_AUTO: {
return DEFAULT_GEMINI_MODEL;
}
case GEMINI_MODEL_ALIAS_AUTO:
case GEMINI_MODEL_ALIAS_PRO: {
return previewFeaturesEnabled
? PREVIEW_GEMINI_MODEL
-22
View File
@@ -25,7 +25,6 @@ import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
import { reportError } from '../utils/errorReporting.js';
import { GeminiChat } from './geminiChat.js';
import { retryWithBackoff } from '../utils/retry.js';
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import { getErrorMessage } from '../utils/errors.js';
import { tokenLimit } from './tokenLimits.js';
import type {
@@ -927,29 +926,8 @@ export class GeminiClient {
// Pass the captured model to the centralized handler.
handleFallback(this.config, currentAttemptModel, authType, error);
const onValidationRequiredCallback = async (
validationError: ValidationRequiredError,
) => {
// Suppress validation dialog for background calls (e.g. prompt-completion)
// to prevent the dialog from appearing on startup or during typing.
if (modelConfigKey.model === 'prompt-completion') {
throw validationError;
}
const handler = this.config.getValidationHandler();
if (typeof handler !== 'function') {
throw validationError;
}
return handler(
validationError.validationLink,
validationError.validationDescription,
validationError.learnMoreUrl,
);
};
const result = await retryWithBackoff(apiCall, {
onPersistent429: onPersistent429Callback,
onValidationRequired: onValidationRequiredCallback,
authType: this.config.getContentGeneratorConfig()?.authType,
maxAttempts: availabilityMaxAttempts,
getAvailabilityContext,
-17
View File
@@ -19,7 +19,6 @@ import type {
import { toParts } from '../code_assist/converter.js';
import { createUserContent, FinishReason } from '@google/genai';
import { retryWithBackoff, isRetryableError } from '../utils/retry.js';
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import type { Config } from '../config/config.js';
import {
resolveModel,
@@ -580,24 +579,8 @@ export class GeminiChat {
error?: unknown,
) => handleFallback(this.config, lastModelToUse, authType, error);
const onValidationRequiredCallback = async (
validationError: ValidationRequiredError,
) => {
const handler = this.config.getValidationHandler();
if (typeof handler !== 'function') {
// No handler registered, re-throw to show default error message
throw validationError;
}
return handler(
validationError.validationLink,
validationError.validationDescription,
validationError.learnMoreUrl,
);
};
const streamResponse = await retryWithBackoff(apiCall, {
onPersistent429: onPersistent429Callback,
onValidationRequired: onValidationRequiredCallback,
authType: this.config.getContentGeneratorConfig()?.authType,
retryFetchErrors: this.config.getRetryFetchErrors(),
signal: abortSignal,
-18
View File
@@ -37,21 +37,3 @@ export type FallbackModelHandler = (
fallbackModel: string,
error?: unknown,
) => Promise<FallbackIntent | null>;
/**
* Defines the intent returned by the UI layer during a validation required scenario.
*/
export type ValidationIntent =
| 'verify' // User chose to verify, wait for completion then retry.
| 'change_auth' // User chose to change authentication method.
| 'cancel'; // User cancelled the verification process.
/**
* The interface for the handler provided by the UI layer (e.g., the CLI)
* to interact with the user when validation is required.
*/
export type ValidationHandler = (
validationLink?: string,
validationDescription?: string,
learnMoreUrl?: string,
) => Promise<ValidationIntent>;
+1 -1
View File
@@ -279,8 +279,8 @@ describe('HookSystem Integration', () => {
],
},
],
disabled: ['echo "disabled-hook"'], // Disable the second hook
},
disabledHooks: ['echo "disabled-hook"'], // Disable the second hook
});
(
+1 -1
View File
@@ -36,6 +36,7 @@ export * from './core/tokenLimits.js';
export * from './core/turn.js';
export * from './core/geminiRequest.js';
export * from './core/coreToolScheduler.js';
export * from './scheduler/scheduler.js';
export * from './scheduler/types.js';
export * from './scheduler/tool-executor.js';
export * from './core/nonInteractiveToolExecutor.js';
@@ -90,7 +91,6 @@ export * from './utils/extensionLoader.js';
export * from './utils/package.js';
export * from './utils/version.js';
export * from './utils/checkpointUtils.js';
export * from './utils/secure-browser-launcher.js';
export * from './utils/apiConversionUtils.js';
export * from './utils/channel.js';
@@ -4,28 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect } from 'vitest';
import { DefaultStrategy } from './defaultStrategy.js';
import type { RoutingContext } from '../routingStrategy.js';
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
import {
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL_AUTO,
GEMINI_MODEL_ALIAS_AUTO,
PREVIEW_GEMINI_FLASH_MODEL,
} from '../../config/models.js';
import { DEFAULT_GEMINI_MODEL } from '../../config/models.js';
import type { Config } from '../../config/config.js';
describe('DefaultStrategy', () => {
it('should route to the default model when requested model is default auto', async () => {
it('should always route to the default Gemini model', async () => {
const strategy = new DefaultStrategy();
const mockContext = {} as RoutingContext;
const mockConfig = {
getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO),
getPreviewFeatures: vi.fn().mockReturnValue(false),
} as unknown as Config;
const mockConfig = {} as Config;
const mockClient = {} as BaseLlmClient;
const decision = await strategy.route(mockContext, mockConfig, mockClient);
@@ -39,89 +29,4 @@ describe('DefaultStrategy', () => {
},
});
});
it('should route to the preview model when requested model is preview auto', async () => {
const strategy = new DefaultStrategy();
const mockContext = {} as RoutingContext;
const mockConfig = {
getModel: vi.fn().mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO),
getPreviewFeatures: vi.fn().mockReturnValue(false),
} as unknown as Config;
const mockClient = {} as BaseLlmClient;
const decision = await strategy.route(mockContext, mockConfig, mockClient);
expect(decision).toEqual({
model: PREVIEW_GEMINI_MODEL,
metadata: {
source: 'default',
latencyMs: 0,
reasoning: `Routing to default model: ${PREVIEW_GEMINI_MODEL}`,
},
});
});
it('should route to the preview model when requested model is auto and previewfeature is on', async () => {
const strategy = new DefaultStrategy();
const mockContext = {} as RoutingContext;
const mockConfig = {
getModel: vi.fn().mockReturnValue(GEMINI_MODEL_ALIAS_AUTO),
getPreviewFeatures: vi.fn().mockReturnValue(true),
} as unknown as Config;
const mockClient = {} as BaseLlmClient;
const decision = await strategy.route(mockContext, mockConfig, mockClient);
expect(decision).toEqual({
model: PREVIEW_GEMINI_MODEL,
metadata: {
source: 'default',
latencyMs: 0,
reasoning: `Routing to default model: ${PREVIEW_GEMINI_MODEL}`,
},
});
});
it('should route to the default model when requested model is auto and previewfeature is off', async () => {
const strategy = new DefaultStrategy();
const mockContext = {} as RoutingContext;
const mockConfig = {
getModel: vi.fn().mockReturnValue(GEMINI_MODEL_ALIAS_AUTO),
getPreviewFeatures: vi.fn().mockReturnValue(false),
} as unknown as Config;
const mockClient = {} as BaseLlmClient;
const decision = await strategy.route(mockContext, mockConfig, mockClient);
expect(decision).toEqual({
model: DEFAULT_GEMINI_MODEL,
metadata: {
source: 'default',
latencyMs: 0,
reasoning: `Routing to default model: ${DEFAULT_GEMINI_MODEL}`,
},
});
});
// this should not happen, adding the test just in case it happens.
it('should route to the same model if it is not an auto mode', async () => {
const strategy = new DefaultStrategy();
const mockContext = {} as RoutingContext;
const mockConfig = {
getModel: vi.fn().mockReturnValue(PREVIEW_GEMINI_FLASH_MODEL),
getPreviewFeatures: vi.fn().mockReturnValue(false),
} as unknown as Config;
const mockClient = {} as BaseLlmClient;
const decision = await strategy.route(mockContext, mockConfig, mockClient);
expect(decision).toEqual({
model: PREVIEW_GEMINI_FLASH_MODEL,
metadata: {
source: 'default',
latencyMs: 0,
reasoning: `Routing to default model: ${PREVIEW_GEMINI_FLASH_MODEL}`,
},
});
});
});
@@ -11,26 +11,22 @@ import type {
RoutingDecision,
TerminalStrategy,
} from '../routingStrategy.js';
import { resolveModel } from '../../config/models.js';
import { DEFAULT_GEMINI_MODEL } from '../../config/models.js';
export class DefaultStrategy implements TerminalStrategy {
readonly name = 'default';
async route(
_context: RoutingContext,
config: Config,
_config: Config,
_baseLlmClient: BaseLlmClient,
): Promise<RoutingDecision> {
const defaultModel = resolveModel(
config.getModel(),
config.getPreviewFeatures(),
);
return {
model: defaultModel,
model: DEFAULT_GEMINI_MODEL,
metadata: {
source: this.name,
latencyMs: 0,
reasoning: `Routing to default model: ${defaultModel}`,
reasoning: `Routing to default model: ${DEFAULT_GEMINI_MODEL}`,
},
};
}
@@ -66,7 +66,7 @@ describe('McpClientManager', () => {
mockConfig.getMcpServers.mockReturnValue({
'test-server': {},
});
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
const manager = new McpClientManager(toolRegistry, mockConfig);
await manager.startConfiguredMcpServers();
expect(mockedMcpClient.connect).toHaveBeenCalledOnce();
expect(mockedMcpClient.discover).toHaveBeenCalledOnce();
@@ -79,7 +79,7 @@ describe('McpClientManager', () => {
'server-2': {},
'server-3': {},
});
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
const manager = new McpClientManager(toolRegistry, mockConfig);
await manager.startConfiguredMcpServers();
// Each client should be connected/discovered
@@ -94,7 +94,7 @@ describe('McpClientManager', () => {
mockConfig.getMcpServers.mockReturnValue({
'test-server': {},
});
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
const manager = new McpClientManager(toolRegistry, mockConfig);
expect(manager.getDiscoveryState()).toBe(MCPDiscoveryState.NOT_STARTED);
const promise = manager.startConfiguredMcpServers();
expect(manager.getDiscoveryState()).toBe(MCPDiscoveryState.IN_PROGRESS);
@@ -107,7 +107,7 @@ describe('McpClientManager', () => {
'test-server': {},
});
mockConfig.isTrustedFolder.mockReturnValue(false);
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
const manager = new McpClientManager(toolRegistry, mockConfig);
await manager.startConfiguredMcpServers();
expect(mockedMcpClient.connect).not.toHaveBeenCalled();
expect(mockedMcpClient.discover).not.toHaveBeenCalled();
@@ -118,7 +118,7 @@ describe('McpClientManager', () => {
'test-server': {},
});
mockConfig.getBlockedMcpServers.mockReturnValue(['test-server']);
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
const manager = new McpClientManager(toolRegistry, mockConfig);
await manager.startConfiguredMcpServers();
expect(mockedMcpClient.connect).not.toHaveBeenCalled();
expect(mockedMcpClient.discover).not.toHaveBeenCalled();
@@ -130,14 +130,14 @@ describe('McpClientManager', () => {
'another-server': {},
});
mockConfig.getAllowedMcpServers.mockReturnValue(['another-server']);
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
const manager = new McpClientManager(toolRegistry, mockConfig);
await manager.startConfiguredMcpServers();
expect(mockedMcpClient.connect).toHaveBeenCalledOnce();
expect(mockedMcpClient.discover).toHaveBeenCalledOnce();
});
it('should start servers from extensions', async () => {
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
const manager = new McpClientManager(toolRegistry, mockConfig);
await manager.startExtension({
name: 'test-extension',
mcpServers: {
@@ -154,7 +154,7 @@ describe('McpClientManager', () => {
});
it('should not start servers from disabled extensions', async () => {
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
const manager = new McpClientManager(toolRegistry, mockConfig);
await manager.startExtension({
name: 'test-extension',
mcpServers: {
@@ -175,7 +175,7 @@ describe('McpClientManager', () => {
'test-server': {},
});
mockConfig.getBlockedMcpServers.mockReturnValue(['test-server']);
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
const manager = new McpClientManager(toolRegistry, mockConfig);
await manager.startConfiguredMcpServers();
expect(manager.getBlockedMcpServers()).toEqual([
{ name: 'test-server', extensionName: '' },
@@ -188,7 +188,7 @@ describe('McpClientManager', () => {
'test-server': {},
});
mockedMcpClient.getServerConfig.mockReturnValue({});
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
const manager = new McpClientManager(toolRegistry, mockConfig);
await manager.startConfiguredMcpServers();
expect(mockedMcpClient.connect).toHaveBeenCalledTimes(1);
@@ -207,7 +207,7 @@ describe('McpClientManager', () => {
'test-server': {},
});
mockedMcpClient.getServerConfig.mockReturnValue({});
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
const manager = new McpClientManager(toolRegistry, mockConfig);
await manager.startConfiguredMcpServers();
expect(mockedMcpClient.connect).toHaveBeenCalledTimes(1);
@@ -221,7 +221,7 @@ describe('McpClientManager', () => {
});
it('should throw an error if the server does not exist', async () => {
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
const manager = new McpClientManager(toolRegistry, mockConfig);
await expect(manager.restartServer('non-existent')).rejects.toThrow(
'No MCP server registered with the name "non-existent"',
);
@@ -247,11 +247,7 @@ describe('McpClientManager', () => {
}) as unknown as McpClient,
);
const manager = new McpClientManager(
'0.0.1',
{} as ToolRegistry,
mockConfig,
);
const manager = new McpClientManager({} as ToolRegistry, mockConfig);
mockConfig.getMcpServers.mockReturnValue({
'server-with-instructions': {},
@@ -286,11 +282,7 @@ describe('McpClientManager', () => {
'test-server': {},
});
const manager = new McpClientManager(
'0.0.1',
{} as ToolRegistry,
mockConfig,
);
const manager = new McpClientManager({} as ToolRegistry, mockConfig);
await expect(manager.startConfiguredMcpServers()).resolves.not.toThrow();
});
@@ -309,11 +301,7 @@ describe('McpClientManager', () => {
'test-server': {},
});
const manager = new McpClientManager(
'0.0.1',
{} as ToolRegistry,
mockConfig,
);
const manager = new McpClientManager({} as ToolRegistry, mockConfig);
await manager.startConfiguredMcpServers();
await expect(manager.restartServer('test-server')).resolves.not.toThrow();
@@ -27,7 +27,6 @@ import { debugLogger } from '../utils/debugLogger.js';
*/
export class McpClientManager {
private clients: Map<string, McpClient> = new Map();
private readonly clientVersion: string;
private readonly toolRegistry: ToolRegistry;
private readonly cliConfig: Config;
// If we have ongoing MCP client discovery, this completes once that is done.
@@ -41,12 +40,10 @@ export class McpClientManager {
}> = [];
constructor(
clientVersion: string,
toolRegistry: ToolRegistry,
cliConfig: Config,
eventEmitter?: EventEmitter,
) {
this.clientVersion = clientVersion;
this.toolRegistry = toolRegistry;
this.cliConfig = cliConfig;
this.eventEmitter = eventEmitter;
@@ -186,7 +183,6 @@ export class McpClientManager {
this.cliConfig.getWorkspaceContext(),
this.cliConfig,
this.cliConfig.getDebugMode(),
this.clientVersion,
async () => {
debugLogger.log('Tools changed, updating Gemini context...');
await this.scheduleMcpContextRefresh();
@@ -133,7 +133,6 @@ describe('mcp-client', () => {
workspaceContext,
{ sanitizationConfig: EMPTY_CONFIG } as Config,
false,
'0.0.1',
);
await client.connect();
await client.discover({} as Config);
@@ -214,7 +213,6 @@ describe('mcp-client', () => {
workspaceContext,
{ sanitizationConfig: EMPTY_CONFIG } as Config,
false,
'0.0.1',
);
await client.connect();
await client.discover({} as Config);
@@ -266,7 +264,6 @@ describe('mcp-client', () => {
workspaceContext,
{ sanitizationConfig: EMPTY_CONFIG } as Config,
false,
'0.0.1',
);
await client.connect();
await expect(client.discover({} as Config)).rejects.toThrow(
@@ -322,7 +319,6 @@ describe('mcp-client', () => {
workspaceContext,
{ sanitizationConfig: EMPTY_CONFIG } as Config,
false,
'0.0.1',
);
await client.connect();
await expect(client.discover({} as Config)).rejects.toThrow(
@@ -382,7 +378,6 @@ describe('mcp-client', () => {
workspaceContext,
{ sanitizationConfig: EMPTY_CONFIG } as Config,
false,
'0.0.1',
);
await client.connect();
await client.discover({} as Config);
@@ -456,7 +451,6 @@ describe('mcp-client', () => {
workspaceContext,
{ sanitizationConfig: EMPTY_CONFIG } as Config,
false,
'0.0.1',
);
await client.connect();
await client.discover({} as Config);
@@ -533,7 +527,6 @@ describe('mcp-client', () => {
workspaceContext,
{ sanitizationConfig: EMPTY_CONFIG } as Config,
false,
'0.0.1',
);
await client.connect();
await client.discover({} as Config);
@@ -617,7 +610,6 @@ describe('mcp-client', () => {
workspaceContext,
{ sanitizationConfig: EMPTY_CONFIG } as Config,
false,
'0.0.1',
);
await client.connect();
await client.discover({} as Config);
@@ -698,7 +690,6 @@ describe('mcp-client', () => {
workspaceContext,
{ sanitizationConfig: EMPTY_CONFIG } as Config,
false,
'0.0.1',
);
await client.connect();
await client.discover({} as Config);
@@ -748,7 +739,6 @@ describe('mcp-client', () => {
workspaceContext,
{ sanitizationConfig: EMPTY_CONFIG } as Config,
false,
'0.0.1',
);
await client.connect();
@@ -785,7 +775,6 @@ describe('mcp-client', () => {
workspaceContext,
{ sanitizationConfig: EMPTY_CONFIG } as Config,
false,
'0.0.1',
);
await client.connect();
@@ -841,7 +830,6 @@ describe('mcp-client', () => {
workspaceContext,
{ sanitizationConfig: EMPTY_CONFIG } as Config,
false,
'0.0.1',
onToolsUpdatedSpy,
);
@@ -912,7 +900,6 @@ describe('mcp-client', () => {
workspaceContext,
{ sanitizationConfig: EMPTY_CONFIG } as Config,
false,
'0.0.1',
);
await client.connect();
@@ -983,7 +970,6 @@ describe('mcp-client', () => {
workspaceContext,
{ sanitizationConfig: EMPTY_CONFIG } as Config,
false,
'0.0.1',
onToolsUpdatedSpy,
);
@@ -996,7 +982,6 @@ describe('mcp-client', () => {
workspaceContext,
{ sanitizationConfig: EMPTY_CONFIG } as Config,
false,
'0.0.1',
onToolsUpdatedSpy,
);
@@ -1079,7 +1064,6 @@ describe('mcp-client', () => {
workspaceContext,
{ sanitizationConfig: EMPTY_CONFIG } as Config,
false,
'0.0.1',
);
await client.connect();
@@ -1144,7 +1128,6 @@ describe('mcp-client', () => {
workspaceContext,
{ sanitizationConfig: EMPTY_CONFIG } as Config,
false,
'0.0.1',
onToolsUpdatedSpy,
);
@@ -1692,7 +1675,6 @@ describe('connectToMcpServer with OAuth', () => {
);
const client = await connectToMcpServer(
'0.0.1',
'test-server',
{ httpUrl: serverUrl, oauth: { enabled: true } },
false,
@@ -1738,7 +1720,6 @@ describe('connectToMcpServer with OAuth', () => {
);
const client = await connectToMcpServer(
'0.0.1',
'test-server',
{ httpUrl: serverUrl, oauth: { enabled: true } },
false,
@@ -1794,7 +1775,6 @@ describe('connectToMcpServer - HTTP→SSE fallback', () => {
await expect(
connectToMcpServer(
'0.0.1',
'test-server',
{ url: 'http://test-server', type: 'http' },
false,
@@ -1814,7 +1794,6 @@ describe('connectToMcpServer - HTTP→SSE fallback', () => {
await expect(
connectToMcpServer(
'0.0.1',
'test-server',
{ url: 'http://test-server', type: 'sse' },
false,
@@ -1833,7 +1812,6 @@ describe('connectToMcpServer - HTTP→SSE fallback', () => {
.mockResolvedValueOnce(undefined);
const client = await connectToMcpServer(
'0.0.1',
'test-server',
{ url: 'http://test-server' },
false,
@@ -1856,7 +1834,6 @@ describe('connectToMcpServer - HTTP→SSE fallback', () => {
await expect(
connectToMcpServer(
'0.0.1',
'test-server',
{ url: 'http://test-server' },
false,
@@ -1874,7 +1851,6 @@ describe('connectToMcpServer - HTTP→SSE fallback', () => {
.mockResolvedValueOnce(undefined);
const client = await connectToMcpServer(
'0.0.1',
'test-server',
{ url: 'http://test-server' },
false,
@@ -1945,7 +1921,6 @@ describe('connectToMcpServer - OAuth with transport fallback', () => {
.mockResolvedValueOnce(undefined);
const client = await connectToMcpServer(
'0.0.1',
'test-server',
{ url: 'http://test-server', oauth: { enabled: true } },
false,
+1 -8
View File
@@ -122,7 +122,6 @@ export class McpClient {
private readonly workspaceContext: WorkspaceContext,
private readonly cliConfig: Config,
private readonly debugMode: boolean,
private readonly clientVersion: string,
private readonly onToolsUpdated?: (signal?: AbortSignal) => Promise<void>,
) {}
@@ -138,7 +137,6 @@ export class McpClient {
this.updateStatus(MCPServerStatus.CONNECTING);
try {
this.client = await connectToMcpServer(
this.clientVersion,
this.serverName,
this.serverConfig,
this.debugMode,
@@ -717,7 +715,6 @@ async function createTransportWithOAuth(
*/
export async function discoverMcpTools(
clientVersion: string,
mcpServers: Record<string, MCPServerConfig>,
mcpServerCommand: string | undefined,
toolRegistry: ToolRegistry,
@@ -733,7 +730,6 @@ export async function discoverMcpTools(
const discoveryPromises = Object.entries(mcpServers).map(
([mcpServerName, mcpServerConfig]) =>
connectAndDiscover(
clientVersion,
mcpServerName,
mcpServerConfig,
toolRegistry,
@@ -812,7 +808,6 @@ export function populateMcpServerCommand(
* @returns Promise that resolves when discovery is complete
*/
export async function connectAndDiscover(
clientVersion: string,
mcpServerName: string,
mcpServerConfig: MCPServerConfig,
toolRegistry: ToolRegistry,
@@ -826,7 +821,6 @@ export async function connectAndDiscover(
let mcpClient: Client | undefined;
try {
mcpClient = await connectToMcpServer(
clientVersion,
mcpServerName,
mcpServerConfig,
debugMode,
@@ -1337,7 +1331,6 @@ async function retryWithOAuth(
* @throws An error if the connection fails or the configuration is invalid.
*/
export async function connectToMcpServer(
clientVersion: string,
mcpServerName: string,
mcpServerConfig: MCPServerConfig,
debugMode: boolean,
@@ -1347,7 +1340,7 @@ export async function connectToMcpServer(
const mcpClient = new Client(
{
name: 'gemini-cli-mcp-client',
version: clientVersion,
version: '0.0.1',
},
{
// Use a tolerant validator so bad output schemas don't block discovery.
+1 -5
View File
@@ -264,10 +264,6 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
return `${this.serverName}__`;
}
getFullyQualifiedName(): string {
return `${this.getFullyQualifiedPrefix()}${generateValidName(this.serverToolName)}`;
}
asFullyQualifiedTool(): DiscoveredMCPTool {
return new DiscoveredMCPTool(
this.mcpTool,
@@ -277,7 +273,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
this.parameterSchema,
this.messageBus,
this.trust,
this.getFullyQualifiedName(),
`${this.getFullyQualifiedPrefix()}${this.serverToolName}`,
this.cliConfig,
this.extensionName,
this.extensionId,
@@ -525,51 +525,6 @@ describe('ToolRegistry', () => {
});
});
describe('getTool', () => {
it('should retrieve an MCP tool by its fully qualified name even if registered with simple name', () => {
const serverName = 'my-server';
const toolName = 'my-tool';
const mcpTool = createMCPTool(serverName, toolName, 'description');
// Register tool (will be registered as 'my-tool' since no conflict)
toolRegistry.registerTool(mcpTool);
// Verify it is available as 'my-tool'
expect(toolRegistry.getTool('my-tool')).toBeDefined();
expect(toolRegistry.getTool('my-tool')?.name).toBe('my-tool');
// Verify it is available as 'my-server__my-tool'
const fullyQualifiedName = `${serverName}__${toolName}`;
const retrievedTool = toolRegistry.getTool(fullyQualifiedName);
expect(retrievedTool).toBeDefined();
// The returned tool object is the same, so its name property is still 'my-tool'
expect(retrievedTool?.name).toBe('my-tool');
});
it('should retrieve an MCP tool by its fully qualified name when tool name has special characters', () => {
const serverName = 'my-server';
// Use a space which is invalid and will be replaced by underscore
const toolName = 'my tool';
const validToolName = 'my_tool';
const mcpTool = createMCPTool(serverName, toolName, 'description');
// Register tool (will be registered as sanitized name)
toolRegistry.registerTool(mcpTool);
// Verify it is available as sanitized name
expect(toolRegistry.getTool(validToolName)).toBeDefined();
expect(toolRegistry.getTool(validToolName)?.name).toBe(validToolName);
// Verify it is available as 'my-server__my_tool'
const fullyQualifiedName = `${serverName}__${validToolName}`;
const retrievedTool = toolRegistry.getTool(fullyQualifiedName);
expect(retrievedTool).toBeDefined();
expect(retrievedTool?.name).toBe(validToolName);
});
});
describe('DiscoveredToolInvocation', () => {
it('should return the stringified params from getDescription', () => {
const tool = new DiscoveredTool(
+1 -12
View File
@@ -530,18 +530,7 @@ export class ToolRegistry {
* Get the definition of a specific tool.
*/
getTool(name: string): AnyDeclarativeTool | undefined {
let tool = this.allKnownTools.get(name);
if (!tool && name.includes('__')) {
for (const t of this.allKnownTools.values()) {
if (t instanceof DiscoveredMCPTool) {
if (t.getFullyQualifiedName() === name) {
tool = t;
break;
}
}
}
}
const tool = this.allKnownTools.get(name);
if (tool && this.isActiveTool(tool)) {
return tool;
}
@@ -9,7 +9,6 @@ import {
classifyGoogleError,
RetryableQuotaError,
TerminalQuotaError,
ValidationRequiredError,
} from './googleQuotaErrors.js';
import * as errorParser from './googleErrors.js';
import type { GoogleApiError } from './googleErrors.js';
@@ -450,190 +449,4 @@ describe('classifyGoogleError', () => {
expect(result.retryDelayMs).toBeUndefined();
}
});
it('should return ValidationRequiredError for 403 with VALIDATION_REQUIRED from cloudcode-pa domain', () => {
const apiError: GoogleApiError = {
code: 403,
message: 'Validation required to continue.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'VALIDATION_REQUIRED',
domain: 'cloudcode-pa.googleapis.com',
metadata: {
validation_link: 'https://fallback.example.com/validate',
},
},
{
'@type': 'type.googleapis.com/google.rpc.Help',
links: [
{
description: 'Complete validation to continue',
url: 'https://example.com/validate',
},
{
description: 'Learn more',
url: 'https://support.google.com/accounts?p=al_alert',
},
],
},
],
};
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
const result = classifyGoogleError(new Error());
expect(result).toBeInstanceOf(ValidationRequiredError);
expect((result as ValidationRequiredError).validationLink).toBe(
'https://example.com/validate',
);
expect((result as ValidationRequiredError).validationDescription).toBe(
'Complete validation to continue',
);
expect((result as ValidationRequiredError).learnMoreUrl).toBe(
'https://support.google.com/accounts?p=al_alert',
);
expect((result as ValidationRequiredError).cause).toBe(apiError);
});
it('should correctly parse Learn more URL when first link description contains "Learn more" text', () => {
// This tests the real API response format where the description of the first
// link contains "Learn more:" text, but we should use the second link's URL
const apiError: GoogleApiError = {
code: 403,
message: 'Validation required to continue.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'VALIDATION_REQUIRED',
domain: 'cloudcode-pa.googleapis.com',
metadata: {},
},
{
'@type': 'type.googleapis.com/google.rpc.Help',
links: [
{
description:
'Further action is required to use this service. Navigate to the following URL to complete verification:\n\nhttps://accounts.sandbox.google.com/signin/continue?...\n\nLearn more:\n\nhttps://support.google.com/accounts?p=al_alert\n',
url: 'https://accounts.sandbox.google.com/signin/continue?sarp=1&scc=1&continue=...',
},
{
description: 'Learn more',
url: 'https://support.google.com/accounts?p=al_alert',
},
],
},
],
};
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
const result = classifyGoogleError(new Error());
expect(result).toBeInstanceOf(ValidationRequiredError);
// Should get the validation link from the first link
expect((result as ValidationRequiredError).validationLink).toBe(
'https://accounts.sandbox.google.com/signin/continue?sarp=1&scc=1&continue=...',
);
// Should get the Learn more URL from the SECOND link, not the first
expect((result as ValidationRequiredError).learnMoreUrl).toBe(
'https://support.google.com/accounts?p=al_alert',
);
});
it('should fallback to ErrorInfo metadata when Help detail is not present', () => {
const apiError: GoogleApiError = {
code: 403,
message: 'Validation required.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'VALIDATION_REQUIRED',
domain: 'staging-cloudcode-pa.googleapis.com',
metadata: {
validation_link: 'https://staging.example.com/validate',
},
},
],
};
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
const result = classifyGoogleError(new Error());
expect(result).toBeInstanceOf(ValidationRequiredError);
expect((result as ValidationRequiredError).validationLink).toBe(
'https://staging.example.com/validate',
);
expect(
(result as ValidationRequiredError).validationDescription,
).toBeUndefined();
expect((result as ValidationRequiredError).learnMoreUrl).toBeUndefined();
});
it('should return original error for 403 with different reason', () => {
const apiError: GoogleApiError = {
code: 403,
message: 'Access denied.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'ACCESS_DENIED',
domain: 'cloudcode-pa.googleapis.com',
metadata: {},
},
],
};
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
const originalError = new Error();
const result = classifyGoogleError(originalError);
expect(result).toBe(originalError);
expect(result).not.toBeInstanceOf(ValidationRequiredError);
});
it('should find learn more link by hostname when description is different', () => {
const apiError: GoogleApiError = {
code: 403,
message: 'Validation required.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'VALIDATION_REQUIRED',
domain: 'cloudcode-pa.googleapis.com',
metadata: {},
},
{
'@type': 'type.googleapis.com/google.rpc.Help',
links: [
{
description: 'Complete validation',
url: 'https://accounts.google.com/validate',
},
{
description: 'More information', // Not exactly "Learn more"
url: 'https://support.google.com/accounts?p=al_alert',
},
],
},
],
};
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
const result = classifyGoogleError(new Error());
expect(result).toBeInstanceOf(ValidationRequiredError);
expect((result as ValidationRequiredError).learnMoreUrl).toBe(
'https://support.google.com/accounts?p=al_alert',
);
});
it('should return original error for 403 from non-cloudcode domain', () => {
const apiError: GoogleApiError = {
code: 403,
message: 'Forbidden.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'VALIDATION_REQUIRED',
domain: 'other.googleapis.com',
metadata: {},
},
],
};
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
const originalError = new Error();
const result = classifyGoogleError(originalError);
expect(result).toBe(originalError);
expect(result).not.toBeInstanceOf(ValidationRequiredError);
});
});
+9 -118
View File
@@ -7,7 +7,6 @@
import type {
ErrorInfo,
GoogleApiError,
Help,
QuotaFailure,
RetryInfo,
} from './googleErrors.js';
@@ -52,30 +51,6 @@ export class RetryableQuotaError extends Error {
}
}
/**
* An error indicating that user validation is required to continue.
*/
export class ValidationRequiredError extends Error {
validationLink?: string;
validationDescription?: string;
learnMoreUrl?: string;
userHandled: boolean = false;
constructor(
message: string,
override readonly cause: GoogleApiError,
validationLink?: string,
validationDescription?: string,
learnMoreUrl?: string,
) {
super(message);
this.name = 'ValidationRequiredError';
this.validationLink = validationLink;
this.validationDescription = validationDescription;
this.learnMoreUrl = learnMoreUrl;
}
}
/**
* Parses a duration string (e.g., "34.074824224s", "60s", "900ms") and returns the time in seconds.
* @param duration The duration string to parse.
@@ -94,94 +69,18 @@ function parseDurationInSeconds(duration: string): number | null {
}
/**
* Valid Cloud Code API domains for VALIDATION_REQUIRED errors.
*/
const CLOUDCODE_DOMAINS = [
'cloudcode-pa.googleapis.com',
'staging-cloudcode-pa.googleapis.com',
'autopush-cloudcode-pa.googleapis.com',
];
/**
* Checks if a 403 error requires user validation and extracts validation details.
* Analyzes a caught error and classifies it as a specific quota-related error if applicable.
*
* @param googleApiError The parsed Google API error to check.
* @returns A `ValidationRequiredError` if validation is required, otherwise `null`.
*/
function classifyValidationRequiredError(
googleApiError: GoogleApiError,
): ValidationRequiredError | null {
const errorInfo = googleApiError.details.find(
(d): d is ErrorInfo =>
d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo',
);
if (!errorInfo) {
return null;
}
if (
!CLOUDCODE_DOMAINS.includes(errorInfo.domain) ||
errorInfo.reason !== 'VALIDATION_REQUIRED'
) {
return null;
}
// Try to extract validation info from Help detail first
const helpDetail = googleApiError.details.find(
(d): d is Help => d['@type'] === 'type.googleapis.com/google.rpc.Help',
);
let validationLink: string | undefined;
let validationDescription: string | undefined;
let learnMoreUrl: string | undefined;
if (helpDetail?.links && helpDetail.links.length > 0) {
// First link is the validation link, extract description and URL
const validationLinkInfo = helpDetail.links[0];
validationLink = validationLinkInfo.url;
validationDescription = validationLinkInfo.description;
// Look for "Learn more" link - identified by description or support.google.com hostname
const learnMoreLink = helpDetail.links.find((link) => {
if (link.description.toLowerCase().trim() === 'learn more') return true;
const parsed = URL.parse(link.url);
return parsed?.hostname === 'support.google.com';
});
if (learnMoreLink) {
learnMoreUrl = learnMoreLink.url;
}
}
// Fallback to ErrorInfo metadata if Help detail not found
if (!validationLink) {
validationLink = errorInfo.metadata?.['validation_link'];
}
return new ValidationRequiredError(
googleApiError.message,
googleApiError,
validationLink,
validationDescription,
learnMoreUrl,
);
}
/**
* Analyzes a caught error and classifies it as a specific error type if applicable.
*
* Classification logic:
* - 404 errors are classified as `ModelNotFoundError`.
* - 403 errors with `VALIDATION_REQUIRED` from cloudcode-pa domains are classified
* as `ValidationRequiredError`.
* - 429 errors are classified as either `TerminalQuotaError` or `RetryableQuotaError`:
* - If the error indicates a daily limit, it's a `TerminalQuotaError`.
* - If the error suggests a retry delay of more than 2 minutes, it's a `TerminalQuotaError`.
* - If the error suggests a retry delay of 2 minutes or less, it's a `RetryableQuotaError`.
* - If the error indicates a per-minute limit, it's a `RetryableQuotaError`.
* - If the error message contains the phrase "Please retry in X[s|ms]", it's a `RetryableQuotaError`.
* It decides whether an error is a `TerminalQuotaError` or a `RetryableQuotaError` based on
* the following logic:
* - If the error indicates a daily limit, it's a `TerminalQuotaError`.
* - If the error suggests a retry delay of more than 2 minutes, it's a `TerminalQuotaError`.
* - If the error suggests a retry delay of 2 minutes or less, it's a `RetryableQuotaError`.
* - If the error indicates a per-minute limit, it's a `RetryableQuotaError`.
* - If the error message contains the phrase "Please retry in X[s|ms]", it's a `RetryableQuotaError`.
*
* @param error The error to classify.
* @returns A classified error or the original `unknown` error.
* @returns A `TerminalQuotaError`, `RetryableQuotaError`, or the original `unknown` error.
*/
export function classifyGoogleError(error: unknown): unknown {
const googleApiError = parseGoogleApiError(error);
@@ -194,14 +93,6 @@ export function classifyGoogleError(error: unknown): unknown {
return new ModelNotFoundError(message, status);
}
// Check for 403 VALIDATION_REQUIRED errors from Cloud Code API
if (status === 403 && googleApiError) {
const validationError = classifyValidationRequiredError(googleApiError);
if (validationError) {
return validationError;
}
}
if (
!googleApiError ||
googleApiError.code !== 429 ||
-25
View File
@@ -9,7 +9,6 @@ import { ApiError } from '@google/genai';
import {
TerminalQuotaError,
RetryableQuotaError,
ValidationRequiredError,
classifyGoogleError,
} from './googleQuotaErrors.js';
import { delay, createAbortError } from './delay.js';
@@ -29,9 +28,6 @@ export interface RetryOptions {
authType?: string,
error?: unknown,
) => Promise<string | boolean | null>;
onValidationRequired?: (
error: ValidationRequiredError,
) => Promise<'verify' | 'change_auth' | 'cancel'>;
authType?: string;
retryFetchErrors?: boolean;
signal?: AbortSignal;
@@ -148,7 +144,6 @@ export async function retryWithBackoff<T>(
initialDelayMs,
maxDelayMs,
onPersistent429,
onValidationRequired,
authType,
shouldRetryOnError,
shouldRetryOnContent,
@@ -225,26 +220,6 @@ export async function retryWithBackoff<T>(
throw classifiedError; // Throw if no fallback or fallback failed.
}
// Handle ValidationRequiredError - user needs to verify before proceeding
if (classifiedError instanceof ValidationRequiredError) {
if (onValidationRequired) {
try {
const intent = await onValidationRequired(classifiedError);
if (intent === 'verify') {
// User verified, retry the request
attempt = 0;
currentDelay = initialDelayMs;
continue;
}
// 'change_auth' or 'cancel' - mark as handled and throw
classifiedError.userHandled = true;
} catch (validationError) {
debugLogger.warn('Validation handler failed:', validationError);
}
}
throw classifiedError;
}
const is500 =
errorCode !== undefined && errorCode >= 500 && errorCode < 600;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.26.0-preview.2",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.26.0-preview.2",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
+9 -15
View File
@@ -1564,8 +1564,8 @@
},
"additionalProperties": false
},
"hooksConfig": {
"title": "HooksConfig",
"hooks": {
"title": "Hooks",
"description": "Hook configurations for intercepting and customizing agent behavior.",
"markdownDescription": "Hook configurations for intercepting and customizing agent behavior.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `{}`",
"default": {},
@@ -1574,8 +1574,8 @@
"enabled": {
"title": "Enable Hooks",
"description": "Canonical toggle for the hooks system. When disabled, no hooks will be executed.",
"markdownDescription": "Canonical toggle for the hooks system. When disabled, no hooks will be executed.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `true`",
"default": true,
"markdownDescription": "Canonical toggle for the hooks system. When disabled, no hooks will be executed.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"disabled": {
@@ -1594,17 +1594,7 @@
"markdownDescription": "Show visual indicators when hooks are executing.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `true`",
"default": true,
"type": "boolean"
}
},
"additionalProperties": false
},
"hooks": {
"title": "Hook Events",
"description": "Event-specific hook configurations.",
"markdownDescription": "Event-specific hook configurations.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `{}`",
"default": {},
"type": "object",
"properties": {
},
"BeforeTool": {
"title": "Before Tool Hooks",
"description": "Hooks that execute before tool execution. Can intercept, validate, or modify tool calls.",
@@ -1956,6 +1946,10 @@
"enabled": {
"type": "boolean",
"description": "Whether to enable the agent."
},
"disabled": {
"type": "boolean",
"description": "Whether to disable the agent."
}
}
},
-1
View File
@@ -197,7 +197,6 @@ export function runSensitiveKeywordLinter() {
console.log('\nRunning sensitive keyword linter...');
const SENSITIVE_PATTERN = /gemini-\d+(\.\d+)?/g;
const ALLOWED_KEYWORDS = new Set([
'gemini-3',
'gemini-3.0',
'gemini-2.5',
'gemini-2.0',