Compare commits

..

37 Commits

Author SHA1 Message Date
gemini-cli-robot 1c207e2f82 chore(release): v0.26.0-preview.3 2026-01-22 22:27:22 +00:00
gemini-cli-robot ee87c98f43 fix(patch): cherry-pick addb57c to release/v0.26.0-preview.2-pr-17331 [CONFLICTS] (#17345)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-22 14:04:49 -08:00
gemini-cli-robot 75b5eeeb12 chore(release): v0.26.0-preview.2 2026-01-22 19:03:19 +00:00
gemini-cli-robot 0fa9a54088 fix(patch): cherry-pick 87a0db2 to release/v0.26.0-preview.1-pr-17308 [CONFLICTS] (#17317)
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
2026-01-22 18:34:24 +00:00
gemini-cli-robot 603e66b2ea chore(release): v0.26.0-preview.1 2026-01-22 01:02:56 +00:00
gemini-cli-robot dc8fc75ac0 fix(patch): cherry-pick 61040d0 to release/v0.26.0-preview.0-pr-17247 to patch version v0.26.0-preview.0 and create version 0.26.0-preview.1 (#17258)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2026-01-22 00:38:07 +00:00
gemini-cli-robot 2b58605227 chore(release): v0.26.0-preview.0 2026-01-21 04:56:03 +00:00
Andrew Garrett 97aac696fb Fix mcp tool lookup in tool registry (#17054)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-01-21 04:18:33 +00:00
Jacob Richman 93ae7772fd Fix bug where System scopes weren't migrated. (#17174) 2026-01-21 04:07:17 +00:00
Tommaso Sciortino 9866eb0551 fix bad fallback logic external editor logic (#17166) 2026-01-21 02:48:44 +00:00
Panagiotis Mantafounis 55c2783e6a fix(cli): display 'http' type on mcp list (#16915)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2026-01-21 02:37:19 +00:00
Tommaso Sciortino 2455f939a3 fix(cli): resolve home/end keybinding conflict (#17124) 2026-01-21 02:15:18 +00:00
Jacob Richman 995ae42f53 Avoid spurious warnings about unexpected renders triggered by appEvents and coreEvents. (#17160) 2026-01-21 00:46:10 +00:00
Adib234 e1fd5be429 Add Esc-Esc to clear prompt when it's not empty (#17131) 2026-01-21 00:32:26 +00:00
Gaurav 3b626e7c61 Add interactive ValidationDialog for handling 403 VALIDATION_REQUIRED errors. (#16231) 2026-01-21 00:23:01 +00:00
Tommaso Sciortino aceb06a587 fix(cli): fix newline support broken in previous PR (#17159) 2026-01-21 00:20:51 +00:00
joshualitt 211d2c5fdd feat(core): Ensure all properties in hooks object are event names. (#16870) 2026-01-20 22:47:31 +00:00
christine betts c9061a1cfe Remove missing sidebar item (#17145) 2026-01-20 22:03:35 +00:00
David Soria Parra b288f124b2 fix(cli): send gemini-cli version as mcp client version (#13407)
Co-authored-by: Taylor Mullen <ntaylormullen@google.com>
2026-01-20 22:01:18 +00:00
imadraude 645e2ec041 fix(cli): resolve Ctrl+Enter and Ctrl+J newline issues (#17021) 2026-01-20 21:56:33 +00:00
N. Taylor Mullen 67d69084e9 docs: update project context and pr-creator workflow (#17119) 2026-01-20 21:20:04 +00:00
Sehoon Shon ed0b0fae49 fix(core): resolve auto model in default strategy (#17116) 2026-01-20 21:03:34 +00:00
Sandy Tao f0f705d3ca feat(core): unify agent enabled and disabled flags (#17127) 2026-01-20 21:00:43 +00:00
joshualitt f42b4c80ac feat(core): Add initial eval for generalist agent. (#16856) 2026-01-20 20:32:48 +00:00
David Zammit b99e841021 Fixes Windows crash: "Cannot resize a pty that has already exited" (#15757)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-01-20 20:14:28 +00:00
Adam Weidman a16d5983cf feat: Add flash lite utility fallback chain (#17056) 2026-01-20 19:43:43 +00:00
Adib234 e5745f16cb feat(plan): telemetry to track adoption and usage of plan mode (#16863) 2026-01-20 18:57:34 +00:00
Christian Gunderman 12b0fe1cc2 Demote the subagent test to nightly (#17105) 2026-01-20 18:18:16 +00:00
Pyush Sinha 5b8a23979f fix(core): update telemetry token count after session resume (#15491)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-20 18:01:39 +00:00
Shreya Keshive b71fe94e0a feat(admin): apply admin settings to gemini skills/mcp/extensions commands (#17102) 2026-01-20 17:52:11 +00:00
Vedant Mahajan e92f60b4fc fix: migrate BeforeModel and AfterModel hooks to HookSystem (#16599)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-20 17:46:54 +00:00
Sandy Tao 15f26175b8 fix(core): Provide compact, actionable errors for agent delegation failures (#16493)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-20 17:30:59 +00:00
christine betts 85b17166a5 Revert "Revert "Update extension examples"" (#16445) 2026-01-20 17:14:46 +00:00
Christian Gunderman 88df6210eb Test coverage for hook exit code cases (#17041) 2026-01-20 17:13:15 +00:00
Shreya Keshive 943481ccc0 feat(admin): set admin.skills.enabled based on advancedFeaturesEnabled setting (#17095) 2026-01-20 16:57:47 +00:00
Coco Sheng 79076d1d52 [A2A] Disable checkpointing if git is not installed (#16896) 2026-01-20 16:38:38 +00:00
christine betts 166e04a8dd Fix mcp instructions (#16439) 2026-01-20 16:10:21 +00:00
126 changed files with 3562 additions and 1232 deletions
+8 -1
View File
@@ -35,7 +35,14 @@ Follow these steps to create a Pull Request:
- **Related Issues**: Link any issues fixed or related to this PR (e.g.,
"Fixes #123").
4. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping
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
issues with multi-line Markdown, write the description to a temporary file
first.
```bash
+72 -420
View File
@@ -1,420 +1,72 @@
## Building and running
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.
To run the full suite of checks, execute the following command:
```bash
npm run preflight
```
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.
## Running Tests in Workspaces\*\*: To run a specific test file within a
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.
- _Example (Core package)_:
`npm test -w @google/gemini-cli-core -- src/routing/modelRouterService.test.ts`
- _Common workspaces_: `@google/gemini-cli`, `@google/gemini-cli-core`.
## Writing Tests
This project uses **Vitest** as its primary testing framework. When writing
tests, aim to follow existing patterns. Key conventions include:
### Test Structure and Framework
- **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`.
# Gemini CLI Project Context
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.
## Project Overview
- **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.
## Building and Running
- **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`
## Testing and Quality
- **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`
## Development Conventions
- **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).
## Documentation
- 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).
+12 -11
View File
@@ -19,8 +19,8 @@ available combinations.
| Action | Keys |
| ------------------------------------------- | ------------------------------------------------------------ |
| 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 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 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. | `Home` |
| Scroll to the bottom. | `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. | `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` |
#### History & Search
@@ -117,7 +117,8 @@ 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: Browse and rewind previous interactions.
- `Esc` pressed twice quickly: Clear the input prompt if it is not empty,
otherwise 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
+5
View File
@@ -17,6 +17,11 @@ policies.
may prompt you to switch to a fallback model (by default always prompts
you).
Some internal utility calls (such as prompt completion and classification)
use a silent fallback chain for `gemini-2.5-flash-lite` and will fall back
to `gemini-2.5-flash` and `gemini-2.5-pro` without prompting or changing the
configured model.
3. **Model switch:** If approved, or if the policy allows for silent fallback,
the CLI will use an available fallback model for the current turn or the
remainder of the session.
+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` |
### Hooks
### HooksConfig
| UI Label | Setting | Description | Default |
| ------------------ | --------------------- | ------------------------------------------------ | ------- |
| Hook Notifications | `hooks.notifications` | Show visual indicators when hooks are executing. | `true` |
| UI Label | Setting | Description | Default |
| ------------------ | --------------------------- | ------------------------------------------------ | ------- |
| Hook Notifications | `hooksConfig.notifications` | Show visual indicators when hooks are executing. | `true` |
<!-- SETTINGS-AUTOGEN:END -->
+15
View File
@@ -18,6 +18,7 @@ Learn how to enable and setup OpenTelemetry for Gemini CLI.
- [Logs and metrics](#logs-and-metrics)
- [Logs](#logs)
- [Sessions](#sessions)
- [Approval Mode](#approval-mode)
- [Tools](#tools)
- [Files](#files)
- [API](#api)
@@ -315,6 +316,20 @@ Captures startup configuration and user prompt submissions.
- `prompt` (string; excluded if `telemetry.logPrompts` is `false`)
- `auth_type` (string)
#### Approval Mode
Tracks changes and duration of approval modes.
- `approval_mode_switch`: Approval mode was changed.
- **Attributes**:
- `from_mode` (string)
- `to_mode` (string)
- `approval_mode_duration`: Duration spent in an approval mode.
- **Attributes**:
- `mode` (string)
- `duration_ms` (int)
#### Tools
Captures tool executions, output truncation, and Edit behavior.
+4
View File
@@ -68,6 +68,10 @@ If you are using the default "pro" model and the CLI detects that you are being
rate-limited, it automatically switches to the "flash" model for the current
session. This allows you to continue working without interruption.
Internal utility calls that use `gemini-2.5-flash-lite` (for example, prompt
completion and classification) silently fall back to `gemini-2.5-flash` and
`gemini-2.5-pro` when quota is exhausted, without changing the configured model.
## File discovery service
The file discovery service is responsible for finding files in the project that
+7 -5
View File
@@ -904,22 +904,24 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `[]`
- **Requires restart:** Yes
#### `hooks`
#### `hooksConfig`
- **`hooks.enabled`** (boolean):
- **`hooksConfig.enabled`** (boolean):
- **Description:** Canonical toggle for the hooks system. When disabled, no
hooks will be executed.
- **Default:** `false`
- **Default:** `true`
- **`hooks.disabled`** (array):
- **`hooksConfig.disabled`** (array):
- **Description:** List of hook names (commands) that should be disabled.
Hooks in this list will not execute even if configured.
- **Default:** `[]`
- **`hooks.notifications`** (boolean):
- **`hooksConfig.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,10 +80,6 @@
"label": "Model selection",
"slug": "docs/cli/model"
},
{
"label": "Rewind",
"slug": "docs/cli/rewind"
},
{
"label": "Sandbox",
"slug": "docs/cli/sandbox"
+10
View File
@@ -304,6 +304,16 @@ export default tseslint.config(
'@typescript-eslint/no-require-imports': 'off',
},
},
// Examples should have access to standard globals like fetch
{
files: ['packages/cli/src/commands/extensions/examples/**/*.js'],
languageOptions: {
globals: {
...globals.node,
fetch: 'readonly',
},
},
},
// extra settings for scripts that we run directly with node
{
files: ['packages/vscode-ide-companion/scripts/**/*.js'],
+48
View File
@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
import path from 'node:path';
import fs from 'node:fs/promises';
describe('generalist_agent', () => {
evalTest('ALWAYS_PASSES', {
name: 'should be able to use generalist agent by explicitly asking the main agent to invoke it',
params: {
settings: {
agents: {
overrides: {
generalist: { enabled: true },
},
},
},
},
prompt:
'Please use the generalist agent to create a file called "generalist_test_file.txt" containing exactly the following text: success',
assert: async (rig) => {
// 1) Verify the generalist agent was invoked via delegate_to_agent
const foundToolCall = await rig.waitForToolCall(
'delegate_to_agent',
undefined,
(args) => {
const parsed = JSON.parse(args);
return parsed.agent_name === 'generalist';
},
);
expect(
foundToolCall,
'Expected to find a delegate_to_agent tool call for generalist agent',
).toBeTruthy();
// 2) Verify the file was created as expected
const filePath = path.join(rig.testDir!, 'generalist_test_file.txt');
const content = await fs.readFile(filePath, 'utf-8');
expect(content.trim()).toBe('success');
},
});
});
+1 -1
View File
@@ -31,7 +31,7 @@ describe('subagent eval test cases', () => {
*
* This tests the system prompt's subagent specific clauses.
*/
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: 'should delegate to user provided agent with relevant expertise',
params: {
settings: {
+9 -3
View File
@@ -53,8 +53,10 @@ describe('Hooks Agent Flow', () => {
await rig.setup('should inject additional context via BeforeAgent hook', {
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeAgent: [
{
hooks: [
@@ -116,8 +118,10 @@ describe('Hooks Agent Flow', () => {
await rig.setup('should receive prompt and response in AfterAgent hook', {
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
AfterAgent: [
{
hooks: [
@@ -163,8 +167,10 @@ describe('Hooks Agent Flow', () => {
'hooks-agent-flow-multistep.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeAgent: [
{
hooks: [
+364 -140
View File
@@ -24,7 +24,7 @@ describe('Hooks System Integration', () => {
describe('Command Hooks - Blocking Behavior', () => {
it('should block tool execution when hook returns block decision', async () => {
await rig.setup(
rig.setup(
'should block tool execution when hook returns block decision',
{
fakeResponsesPath: join(
@@ -32,8 +32,10 @@ describe('Hooks System Integration', () => {
'hooks-system.block-tool.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
matcher: 'write_file',
@@ -75,8 +77,67 @@ describe('Hooks System Integration', () => {
expect(hookTelemetryFound).toBeTruthy();
});
it('should block tool execution and use stderr as reason when hook exits with code 2', async () => {
rig.setup(
'should block tool execution and use stderr as reason when hook exits with code 2',
{
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.block-tool.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
matcher: 'write_file',
hooks: [
{
type: 'command',
// Exit with code 2 and write reason to stderr
command:
'node -e "process.stderr.write(\'File writing blocked by security policy\'); process.exit(2)"',
timeout: 5000,
},
],
},
],
},
},
},
);
const result = await rig.run({
args: 'Create a file called test.txt with content "Hello World"',
});
// The hook should block the write_file tool
const toolLogs = rig.readToolLogs();
const writeFileCalls = toolLogs.filter(
(t) =>
t.toolRequest.name === 'write_file' && t.toolRequest.success === true,
);
// Tool should not be called due to blocking hook
expect(writeFileCalls).toHaveLength(0);
// Result should mention the blocking reason from stderr
expect(result).toContain('File writing blocked by security policy');
// Verify hook telemetry shows exit code 2 and stderr
const hookLogs = rig.readHookLogs();
const blockHook = hookLogs.find((log) => log.hookCall.exit_code === 2);
expect(blockHook).toBeDefined();
expect(blockHook?.hookCall.stderr).toContain(
'File writing blocked by security policy',
);
expect(blockHook?.hookCall.success).toBe(false);
});
it('should allow tool execution when hook returns allow decision', async () => {
await rig.setup(
rig.setup(
'should allow tool execution when hook returns allow decision',
{
fakeResponsesPath: join(
@@ -84,8 +145,10 @@ describe('Hooks System Integration', () => {
'hooks-system.allow-tool.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
matcher: 'write_file',
@@ -126,14 +189,16 @@ describe('Hooks System Integration', () => {
it('should add additional context from AfterTool hooks', async () => {
const command =
"node -e \"console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'AfterTool', additionalContext: 'Security scan: File content appears safe'}}))\"";
await rig.setup('should add additional context from AfterTool hooks', {
rig.setup('should add additional context from AfterTool hooks', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.after-tool-context.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
AfterTool: [
{
matcher: 'read_file',
@@ -178,7 +243,7 @@ describe('Hooks System Integration', () => {
it('should modify LLM requests with BeforeModel hooks', async () => {
// Create a hook script that replaces the LLM request with a modified version
// Note: Providing messages in the hook output REPLACES the entire conversation
await rig.setup('should modify LLM requests with BeforeModel hooks', {
rig.setup('should modify LLM requests with BeforeModel hooks', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.before-model.responses',
@@ -203,10 +268,12 @@ console.log(JSON.stringify({
const scriptPath = join(rig.testDir!, 'before_model_hook.cjs');
writeFileSync(scriptPath, hookScript);
await rig.setup('should modify LLM requests with BeforeModel hooks', {
rig.setup('should modify LLM requests with BeforeModel hooks', {
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeModel: [
{
hooks: [
@@ -248,13 +315,103 @@ console.log(JSON.stringify({
expect(hookTelemetryFound[0].hookCall.stdout).toBeDefined();
expect(hookTelemetryFound[0].hookCall.stderr).toBeDefined();
});
it('should block model execution when BeforeModel hook returns deny decision', async () => {
rig.setup(
'should block model execution when BeforeModel hook returns deny decision',
);
const hookScript = `console.log(JSON.stringify({
decision: "deny",
reason: "Model execution blocked by security policy"
}));`;
const scriptPath = join(rig.testDir!, 'before_model_deny_hook.cjs');
writeFileSync(scriptPath, hookScript);
rig.setup(
'should block model execution when BeforeModel hook returns deny decision',
{
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeModel: [
{
hooks: [
{
type: 'command',
command: `node "${scriptPath}"`,
timeout: 5000,
},
],
},
],
},
},
},
);
const result = await rig.run({ args: 'Hello' });
// The hook should have blocked the request
expect(result).toContain('Model execution blocked by security policy');
// Verify no API requests were made to the LLM
const apiRequests = rig.readAllApiRequest();
expect(apiRequests).toHaveLength(0);
});
it('should block model execution when BeforeModel hook returns block decision', async () => {
rig.setup(
'should block model execution when BeforeModel hook returns block decision',
);
const hookScript = `console.log(JSON.stringify({
decision: "block",
reason: "Model execution blocked by security policy"
}));`;
const scriptPath = join(rig.testDir!, 'before_model_block_hook.cjs');
writeFileSync(scriptPath, hookScript);
rig.setup(
'should block model execution when BeforeModel hook returns block decision',
{
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeModel: [
{
hooks: [
{
type: 'command',
command: `node "${scriptPath}"`,
timeout: 5000,
},
],
},
],
},
},
},
);
const result = await rig.run({ args: 'Hello' });
// The hook should have blocked the request
expect(result).toContain('Model execution blocked by security policy');
// Verify no API requests were made to the LLM
const apiRequests = rig.readAllApiRequest();
expect(apiRequests).toHaveLength(0);
});
});
describe('AfterModel Hooks - LLM Response Modification', () => {
it.skipIf(process.platform === 'win32')(
'should modify LLM responses with AfterModel hooks',
async () => {
await rig.setup('should modify LLM responses with AfterModel hooks', {
rig.setup('should modify LLM responses with AfterModel hooks', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.after-model.responses',
@@ -284,10 +441,12 @@ console.log(JSON.stringify({
const scriptPath = join(rig.testDir!, 'after_model_hook.cjs');
writeFileSync(scriptPath, hookScript);
await rig.setup('should modify LLM responses with AfterModel hooks', {
rig.setup('should modify LLM responses with AfterModel hooks', {
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
AfterModel: [
{
hooks: [
@@ -319,41 +478,37 @@ console.log(JSON.stringify({
describe('BeforeToolSelection Hooks - Tool Configuration', () => {
it('should modify tool selection with BeforeToolSelection hooks', async () => {
await rig.setup(
'should modify tool selection with BeforeToolSelection hooks',
{
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.before-tool-selection.responses',
),
},
);
rig.setup('should modify tool selection with BeforeToolSelection hooks', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.before-tool-selection.responses',
),
});
// Create inline hook command (works on both Unix and Windows)
const hookCommand =
"node -e \"console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'BeforeToolSelection', toolConfig: {mode: 'ANY', allowedFunctionNames: ['read_file', 'run_shell_command']}}}))\"";
await rig.setup(
'should modify tool selection with BeforeToolSelection hooks',
{
settings: {
debugMode: true,
hooks: {
enabled: true,
BeforeToolSelection: [
{
hooks: [
{
type: 'command',
command: hookCommand,
timeout: 5000,
},
],
},
],
},
rig.setup('should modify tool selection with BeforeToolSelection hooks', {
settings: {
debugMode: true,
hooksConfig: {
enabled: true,
},
hooks: {
BeforeToolSelection: [
{
hooks: [
{
type: 'command',
command: hookCommand,
timeout: 5000,
},
],
},
],
},
},
);
});
// Create a test file
rig.createFile('new_file_data.txt', 'test data');
@@ -382,7 +537,7 @@ console.log(JSON.stringify({
describe('BeforeAgent Hooks - Prompt Augmentation', () => {
it('should augment prompts with BeforeAgent hooks', async () => {
await rig.setup('should augment prompts with BeforeAgent hooks', {
rig.setup('should augment prompts with BeforeAgent hooks', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.before-agent.responses',
@@ -401,10 +556,12 @@ console.log(JSON.stringify({
const scriptPath = join(rig.testDir!, 'before_agent_hook.cjs');
writeFileSync(scriptPath, hookScript);
await rig.setup('should augment prompts with BeforeAgent hooks', {
rig.setup('should augment prompts with BeforeAgent hooks', {
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeAgent: [
{
hooks: [
@@ -438,7 +595,7 @@ console.log(JSON.stringify({
const hookCommand =
'node -e "console.log(JSON.stringify({suppressOutput: false, systemMessage: \'Permission request logged by security hook\'}))"';
await rig.setup('should handle notification hooks for tool permissions', {
rig.setup('should handle notification hooks for tool permissions', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.notification.responses',
@@ -449,8 +606,10 @@ console.log(JSON.stringify({
approval: 'ASK', // Disable YOLO mode to show permission prompts
confirmationRequired: ['run_shell_command'],
},
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
Notification: [
{
matcher: 'ToolPermission',
@@ -534,14 +693,16 @@ console.log(JSON.stringify({
const hook2Command =
"node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'Step 2: Security check completed.'}}))\"";
await rig.setup('should execute hooks sequentially when configured', {
rig.setup('should execute hooks sequentially when configured', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.sequential-execution.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeAgent: [
{
sequential: true,
@@ -594,7 +755,7 @@ console.log(JSON.stringify({
describe('Hook Input/Output Validation', () => {
it('should provide correct input format to hooks', async () => {
await rig.setup('should provide correct input format to hooks', {
rig.setup('should provide correct input format to hooks', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.input-validation.responses',
@@ -618,10 +779,12 @@ try {
const scriptPath = join(rig.testDir!, 'input_validation_hook.cjs');
writeFileSync(scriptPath, hookScript);
await rig.setup('should provide correct input format to hooks', {
rig.setup('should provide correct input format to hooks', {
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
hooks: [
@@ -653,6 +816,52 @@ try {
const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call');
expect(hookTelemetryFound).toBeTruthy();
});
it('should treat mixed stdout (text + JSON) as system message and allow execution when exit code is 0', async () => {
rig.setup(
'should treat mixed stdout (text + JSON) as system message and allow execution when exit code is 0',
{
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.allow-tool.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
matcher: 'write_file',
hooks: [
{
type: 'command',
// Output plain text then JSON.
// This breaks JSON parsing, so it falls back to 'allow' with the whole stdout as systemMessage.
command:
"node -e \"console.log('Pollution'); console.log(JSON.stringify({decision: 'deny', reason: 'Should be ignored'}))\"",
timeout: 5000,
},
],
},
],
},
},
},
);
const result = await rig.run({
args: 'Create a file called approved.txt with content "Approved content"',
});
// The hook logic fails to parse JSON, so it allows the tool.
const foundWriteFile = await rig.waitForToolCall('write_file');
expect(foundWriteFile).toBeTruthy();
// The entire stdout (including the JSON part) becomes the systemMessage
expect(result).toContain('Pollution');
expect(result).toContain('Should be ignored');
});
});
describe('Multiple Event Types', () => {
@@ -665,14 +874,16 @@ try {
const beforeAgentCommand =
"node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'BeforeAgent: User request processed'}}))\"";
await rig.setup('should handle hooks for all major event types', {
rig.setup('should handle hooks for all major event types', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.multiple-events.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeAgent: [
{
hooks: [
@@ -768,7 +979,7 @@ try {
describe('Hook Error Handling', () => {
it('should handle hook failures gracefully', async () => {
await rig.setup('should handle hook failures gracefully', {
rig.setup('should handle hook failures gracefully', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.error-handling.responses',
@@ -782,10 +993,12 @@ try {
const workingCommand =
"node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Working hook succeeded'}))\"";
await rig.setup('should handle hook failures gracefully', {
rig.setup('should handle hook failures gracefully', {
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
hooks: [
@@ -830,14 +1043,16 @@ try {
const hookCommand =
"node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Telemetry test hook'}))\"";
await rig.setup('should generate telemetry events for hook executions', {
rig.setup('should generate telemetry events for hook executions', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.telemetry.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
hooks: [
@@ -871,14 +1086,16 @@ try {
const sessionStartCommand =
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting on startup'}))\"";
await rig.setup('should fire SessionStart hook on app startup', {
rig.setup('should fire SessionStart hook on app startup', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.session-startup.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
SessionStart: [
{
matcher: 'startup',
@@ -936,7 +1153,7 @@ console.log(JSON.stringify({
}
}));`;
await rig.setup('should fire SessionStart hook and inject context', {
rig.setup('should fire SessionStart hook and inject context', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.session-startup.responses',
@@ -946,10 +1163,12 @@ console.log(JSON.stringify({
const scriptPath = join(rig.testDir!, 'session_start_context_hook.cjs');
writeFileSync(scriptPath, hookScript);
await rig.setup('should fire SessionStart hook and inject context', {
rig.setup('should fire SessionStart hook and inject context', {
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
SessionStart: [
{
matcher: 'startup',
@@ -1011,7 +1230,7 @@ console.log(JSON.stringify({
}
}));`;
await rig.setup(
rig.setup(
'should fire SessionStart hook and display systemMessage in interactive mode',
{
fakeResponsesPath: join(
@@ -1027,12 +1246,14 @@ console.log(JSON.stringify({
);
writeFileSync(scriptPath, hookScript);
await rig.setup(
rig.setup(
'should fire SessionStart hook and display systemMessage in interactive mode',
{
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
SessionStart: [
{
matcher: 'startup',
@@ -1091,7 +1312,7 @@ console.log(JSON.stringify({
const sessionStartCommand =
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting after clear'}))\"";
await rig.setup(
rig.setup(
'should fire SessionEnd and SessionStart hooks on /clear command',
{
fakeResponsesPath: join(
@@ -1099,8 +1320,10 @@ console.log(JSON.stringify({
'hooks-system.session-clear.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
SessionEnd: [
{
matcher: '*',
@@ -1265,14 +1488,16 @@ console.log(JSON.stringify({
const preCompressCommand =
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'PreCompress hook executed for automatic compression'}))\"";
await rig.setup('should fire PreCompress hook on automatic compression', {
rig.setup('should fire PreCompress hook on automatic compression', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.compress-auto.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
PreCompress: [
{
matcher: 'auto',
@@ -1330,14 +1555,16 @@ console.log(JSON.stringify({
const sessionEndCommand =
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'SessionEnd hook executed on exit'}))\"";
await rig.setup('should fire SessionEnd hook on graceful exit', {
rig.setup('should fire SessionEnd hook on graceful exit', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.session-startup.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
SessionEnd: [
{
matcher: 'exit',
@@ -1412,7 +1639,7 @@ console.log(JSON.stringify({
describe('Hook Disabling', () => {
it('should not execute hooks disabled in settings file', async () => {
await rig.setup('should not execute hooks disabled in settings file', {
rig.setup('should not execute hooks disabled in settings file', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.disabled-via-settings.responses',
@@ -1432,10 +1659,13 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
writeFileSync(enabledPath, enabledHookScript);
writeFileSync(disabledPath, disabledHookScript);
await rig.setup('should not execute hooks disabled in settings file', {
rig.setup('should not execute hooks disabled in settings file', {
settings: {
hooks: {
hooksConfig: {
enabled: true,
disabled: [`node "${disabledPath}"`], // Disable the second hook
},
hooks: {
BeforeTool: [
{
hooks: [
@@ -1452,7 +1682,6 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
],
},
],
disabled: [`node "${disabledPath}"`], // Disable the second hook
},
},
});
@@ -1487,15 +1716,12 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
});
it('should respect disabled hooks across multiple operations', async () => {
await rig.setup(
'should respect disabled hooks across multiple operations',
{
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.disabled-via-command.responses',
),
},
);
rig.setup('should respect disabled hooks across multiple operations', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.disabled-via-command.responses',
),
});
// Create two hook scripts - one that will be disabled, one that won't
const activeHookScript = `const fs = require('fs');
@@ -1510,33 +1736,32 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
writeFileSync(activePath, activeHookScript);
writeFileSync(disabledPath, disabledHookScript);
await rig.setup(
'should respect disabled hooks across multiple operations',
{
settings: {
hooks: {
enabled: true,
BeforeTool: [
{
hooks: [
{
type: 'command',
command: `node "${activePath}"`,
timeout: 5000,
},
{
type: 'command',
command: `node "${disabledPath}"`,
timeout: 5000,
},
],
},
],
disabled: [`node "${disabledPath}"`], // Disable the second hook
},
rig.setup('should respect disabled hooks across multiple operations', {
settings: {
hooksConfig: {
enabled: true,
disabled: [`node "${disabledPath}"`], // Disable the second hook,
},
hooks: {
BeforeTool: [
{
hooks: [
{
type: 'command',
command: `node "${activePath}"`,
timeout: 5000,
},
{
type: 'command',
command: `node "${disabledPath}"`,
timeout: 5000,
},
],
},
],
},
},
);
});
// First run - only active hook should execute
const result1 = await rig.run({
@@ -1587,9 +1812,7 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
describe('BeforeTool Hooks - Input Override', () => {
it('should override tool input parameters via BeforeTool hook', async () => {
// 1. First setup to get the test directory and prepare the hook script
await rig.setup(
'should override tool input parameters via BeforeTool hook',
);
rig.setup('should override tool input parameters via BeforeTool hook');
// Create a hook script that overrides the tool input
const hookOutput = {
@@ -1616,32 +1839,31 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
const commandPath = scriptPath.replace(/\\/g, '/');
// 2. Full setup with settings and fake responses
await rig.setup(
'should override tool input parameters via BeforeTool hook',
{
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.input-modification.responses',
),
settings: {
hooks: {
enabled: true,
BeforeTool: [
{
matcher: 'write_file',
hooks: [
{
type: 'command',
command: `node "${commandPath}"`,
timeout: 5000,
},
],
},
],
},
rig.setup('should override tool input parameters via BeforeTool hook', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.input-modification.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
matcher: 'write_file',
hooks: [
{
type: 'command',
command: `node "${commandPath}"`,
timeout: 5000,
},
],
},
],
},
},
);
});
// Run the agent. The fake response will attempt to call write_file with
// file_path="original.txt" and content="original content"
@@ -1698,19 +1920,21 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
hookOutput,
)}));`;
await rig.setup('should stop agent execution via BeforeTool hook');
rig.setup('should stop agent execution via BeforeTool hook');
const scriptPath = join(rig.testDir!, 'before_tool_stop_hook.js');
writeFileSync(scriptPath, hookScript);
const commandPath = scriptPath.replace(/\\/g, '/');
await rig.setup('should stop agent execution via BeforeTool hook', {
rig.setup('should stop agent execution via BeforeTool hook', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.before-tool-stop.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
matcher: 'write_file',
+22 -33
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.3",
"workspaces": [
"packages/*"
],
@@ -2474,7 +2474,6 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2655,7 +2654,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2689,7 +2687,6 @@
"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"
},
@@ -3058,7 +3055,6 @@
"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"
@@ -3092,7 +3088,6 @@
"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"
@@ -3145,7 +3140,6 @@
"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",
@@ -4358,7 +4352,6 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4636,7 +4629,6 @@
"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",
@@ -5641,7 +5633,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6086,7 +6077,8 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/array-includes": {
"version": "3.1.9",
@@ -7370,6 +7362,7 @@
"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"
},
@@ -8689,7 +8682,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -9292,6 +9284,7 @@
"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"
}
@@ -9301,6 +9294,7 @@
"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"
}
@@ -9310,6 +9304,7 @@
"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"
}
@@ -9563,6 +9558,7 @@
"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",
@@ -9581,6 +9577,7 @@
"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"
}
@@ -9589,13 +9586,15 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"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"
}
@@ -10878,7 +10877,6 @@
"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",
@@ -14063,7 +14061,8 @@
"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"
"license": "MIT",
"peer": true
},
"node_modules/path-type": {
"version": "3.0.0",
@@ -14640,7 +14639,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14651,7 +14649,6 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -16911,7 +16908,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17135,8 +17131,7 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -17144,7 +17139,6 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -17328,7 +17322,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -17491,6 +17484,7 @@
"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"
}
@@ -17545,7 +17539,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -17659,7 +17652,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17672,7 +17664,6 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -18377,7 +18368,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -18393,7 +18383,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.3",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
"@google-cloud/storage": "^7.16.0",
@@ -18703,7 +18693,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.3",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -18807,7 +18797,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.3",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
@@ -18944,7 +18934,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -18967,7 +18956,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.3",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18984,7 +18973,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.3",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.3",
"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-nightly.20260115.6cb3ae4e0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-preview.3"
},
"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-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.3",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+4 -1
View File
@@ -575,7 +575,10 @@ export class Task {
EDIT_TOOL_NAMES.has(request.name),
);
if (restorableToolCalls.length > 0) {
if (
restorableToolCalls.length > 0 &&
this.config.getCheckpointingEnabled()
) {
const gitService = await this.config.getGitService();
if (gitService) {
const { checkpointsToWrite, toolCallToCheckpointMap, errors } =
@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { loadConfig } from './config.js';
import type { ExtensionLoader } from '@google/gemini-cli-core';
import type { Settings } from './settings.js';
const {
mockLoadServerHierarchicalMemory,
mockConfigConstructor,
mockVerifyGitAvailability,
} = vi.hoisted(() => ({
mockLoadServerHierarchicalMemory: vi.fn().mockResolvedValue({
memoryContent: '',
fileCount: 0,
filePaths: [],
}),
mockConfigConstructor: vi.fn(),
mockVerifyGitAvailability: vi.fn(),
}));
vi.mock('@google/gemini-cli-core', async () => ({
Config: class MockConfig {
constructor(params: unknown) {
mockConfigConstructor(params);
}
initialize = vi.fn();
refreshAuth = vi.fn();
},
loadServerHierarchicalMemory: mockLoadServerHierarchicalMemory,
startupProfiler: {
flush: vi.fn(),
},
FileDiscoveryService: vi.fn(),
ApprovalMode: { DEFAULT: 'default', YOLO: 'yolo' },
AuthType: {
LOGIN_WITH_GOOGLE: 'login_with_google',
USE_GEMINI: 'use_gemini',
},
GEMINI_DIR: '.gemini',
DEFAULT_GEMINI_EMBEDDING_MODEL: 'models/embedding-001',
DEFAULT_GEMINI_MODEL: 'models/gemini-1.5-flash',
PREVIEW_GEMINI_MODEL: 'models/gemini-1.5-pro-latest',
homedir: () => '/tmp',
GitService: {
verifyGitAvailability: mockVerifyGitAvailability,
},
}));
describe('loadConfig', () => {
const mockSettings = {
checkpointing: { enabled: true },
};
const mockExtensionLoader = {
start: vi.fn(),
getExtensions: vi.fn().mockReturnValue([]),
} as unknown as ExtensionLoader;
beforeEach(() => {
vi.resetAllMocks();
process.env['GEMINI_API_KEY'] = 'test-key';
// Reset the mock return value just in case
mockLoadServerHierarchicalMemory.mockResolvedValue({
memoryContent: '',
fileCount: 0,
filePaths: [],
});
});
afterEach(() => {
delete process.env['GEMINI_API_KEY'];
delete process.env['CHECKPOINTING'];
});
it('should disable checkpointing if git is not installed', async () => {
mockVerifyGitAvailability.mockResolvedValue(false);
await loadConfig(
mockSettings as unknown as Settings,
mockExtensionLoader,
'test-task',
);
expect(mockConfigConstructor).toHaveBeenCalledWith(
expect.objectContaining({
checkpointing: false,
}),
);
});
it('should enable checkpointing if git is installed', async () => {
mockVerifyGitAvailability.mockResolvedValue(true);
await loadConfig(
mockSettings as unknown as Settings,
mockExtensionLoader,
'test-task',
);
expect(mockConfigConstructor).toHaveBeenCalledWith(
expect.objectContaining({
checkpointing: true,
}),
);
});
});
+15 -3
View File
@@ -23,6 +23,7 @@ import {
startupProfiler,
PREVIEW_GEMINI_MODEL,
homedir,
GitService,
} from '@google/gemini-cli-core';
import { logger } from '../utils/logger.js';
@@ -41,6 +42,19 @@ export async function loadConfig(
settings.folderTrust === true ||
process.env['GEMINI_FOLDER_TRUST'] === 'true';
let checkpointing = process.env['CHECKPOINTING']
? process.env['CHECKPOINTING'] === 'true'
: settings.checkpointing?.enabled;
if (checkpointing) {
if (!(await GitService.verifyGitAvailability())) {
logger.warn(
'[Config] Checkpointing is enabled but git is not installed. Disabling checkpointing.',
);
checkpointing = false;
}
}
const configParams: ConfigParameters = {
sessionId: taskId,
model: settings.general?.previewFeatures
@@ -79,9 +93,7 @@ export async function loadConfig(
folderTrust,
trustedFolder: true,
extensionLoader,
checkpointing: process.env['CHECKPOINTING']
? process.env['CHECKPOINTING'] === 'true'
: settings.checkpointing?.enabled,
checkpointing,
previewFeatures: settings.general?.previewFeatures,
interactive: true,
enableInteractiveShell: true,
+24
View File
@@ -11,6 +11,30 @@ import { FatalError, writeToStderr } from '@google/gemini-cli-core';
import { runExitCleanup } from './src/utils/cleanup.js';
// --- Global Entry Point ---
// Suppress known race condition error in node-pty on Windows
// Tracking bug: https://github.com/microsoft/node-pty/issues/827
process.on('uncaughtException', (error) => {
if (
process.platform === 'win32' &&
error instanceof Error &&
error.message === 'Cannot resize a pty that has already exited'
) {
// This error happens on Windows with node-pty when resizing a pty that has just exited.
// It is a race condition in node-pty that we cannot prevent, so we silence it.
return;
}
// For other errors, we rely on the default behavior, but since we attached a listener,
// we must manually replicate it.
if (error instanceof Error) {
writeToStderr(error.stack + '\n');
} else {
writeToStderr(String(error) + '\n');
}
process.exit(1);
});
main().catch(async (error) => {
await runExitCleanup();
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.3",
"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-nightly.20260115.6cb3ae4e0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-preview.3"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
+27 -9
View File
@@ -54,15 +54,33 @@ describe('extensionsCommand', () => {
extensionsCommand.builder(mockYargs);
expect(mockYargs.middleware).toHaveBeenCalled();
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'install' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'uninstall' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'list' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'update' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'disable' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'enable' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'link' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'new' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'validate' });
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'install' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'uninstall' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'list' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'update' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'disable' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'enable' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'link' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'new' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'validate' }),
);
expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String));
expect(mockYargs.version).toHaveBeenCalledWith(false);
});
+11 -10
View File
@@ -16,6 +16,7 @@ import { newCommand } from './extensions/new.js';
import { validateCommand } from './extensions/validate.js';
import { configureCommand } from './extensions/configure.js';
import { initializeOutputListenersAndFlush } from '../gemini.js';
import { defer } from '../deferred.js';
export const extensionsCommand: CommandModule = {
command: 'extensions <command>',
@@ -24,16 +25,16 @@ export const extensionsCommand: CommandModule = {
builder: (yargs) =>
yargs
.middleware(() => initializeOutputListenersAndFlush())
.command(installCommand)
.command(uninstallCommand)
.command(listCommand)
.command(updateCommand)
.command(disableCommand)
.command(enableCommand)
.command(linkCommand)
.command(newCommand)
.command(validateCommand)
.command(configureCommand)
.command(defer(installCommand, 'extensions'))
.command(defer(uninstallCommand, 'extensions'))
.command(defer(listCommand, 'extensions'))
.command(defer(updateCommand, 'extensions'))
.command(defer(disableCommand, 'extensions'))
.command(defer(enableCommand, 'extensions'))
.command(defer(linkCommand, 'extensions'))
.command(defer(newCommand, 'extensions'))
.command(defer(validateCommand, 'extensions'))
.command(defer(configureCommand, 'extensions'))
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {
@@ -0,0 +1,4 @@
{
"name": "hooks-example",
"version": "1.0.0"
}
@@ -0,0 +1,14 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node ${extensionPath}/scripts/on-start.js"
}
]
}
]
}
}
@@ -0,0 +1,8 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
console.log(
'Session Started! This is running from a script in the hooks-example extension.',
);
@@ -0,0 +1,35 @@
# MCP Server Example
This is a basic example of an MCP (Model Context Protocol) server used as a
Gemini CLI extension. It demonstrates how to expose tools and prompts to the
Gemini CLI.
## Description
The contents of this directory are a valid MCP server implementation using the
`@modelcontextprotocol/sdk`. It exposes:
- A tool `fetch_posts` that mock-fetches posts.
- A prompt `poem-writer`.
## Structure
- `example.js`: The main server entry point.
- `gemini-extension.json`: The configuration file that tells Gemini CLI how to
use this extension.
- `package.json`: Helper for dependencies.
## How to Use
1. Navigate to this directory:
```bash
cd packages/cli/src/commands/extensions/examples/mcp-server
```
2. Install dependencies:
```bash
npm install
```
This example is typically used by `gemini extensions new`.
@@ -1,135 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
// Mock the MCP server and transport
const mockRegisterTool = vi.fn();
const mockRegisterPrompt = vi.fn();
const mockConnect = vi.fn();
vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
McpServer: vi.fn().mockImplementation(() => ({
registerTool: mockRegisterTool,
registerPrompt: mockRegisterPrompt,
connect: mockConnect,
})),
}));
vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
StdioServerTransport: vi.fn(),
}));
describe('MCP Server Example', () => {
beforeEach(async () => {
// Dynamically import the server setup after mocks are in place
await import('./example.js');
});
afterEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
it('should create an McpServer with the correct name and version', () => {
expect(McpServer).toHaveBeenCalledWith({
name: 'prompt-server',
version: '1.0.0',
});
});
it('should register the "fetch_posts" tool', () => {
expect(mockRegisterTool).toHaveBeenCalledWith(
'fetch_posts',
{
description: 'Fetches a list of posts from a public API.',
inputSchema: z.object({}).shape,
},
expect.any(Function),
);
});
it('should register the "poem-writer" prompt', () => {
expect(mockRegisterPrompt).toHaveBeenCalledWith(
'poem-writer',
{
title: 'Poem Writer',
description: 'Write a nice haiku',
argsSchema: expect.any(Object),
},
expect.any(Function),
);
});
it('should connect the server to an StdioServerTransport', () => {
expect(StdioServerTransport).toHaveBeenCalled();
expect(mockConnect).toHaveBeenCalledWith(expect.any(StdioServerTransport));
});
describe('fetch_posts tool implementation', () => {
it('should fetch posts and return a formatted response', async () => {
const mockPosts = [
{ id: 1, title: 'Post 1' },
{ id: 2, title: 'Post 2' },
];
global.fetch = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue(mockPosts),
});
const toolFn = mockRegisterTool.mock.calls[0][2];
const result = await toolFn();
expect(global.fetch).toHaveBeenCalledWith(
'https://jsonplaceholder.typicode.com/posts',
);
expect(result).toEqual({
content: [
{
type: 'text',
text: JSON.stringify({ posts: mockPosts }),
},
],
});
});
});
describe('poem-writer prompt implementation', () => {
it('should generate a prompt with a title', () => {
const promptFn = mockRegisterPrompt.mock.calls[0][2];
const result = promptFn({ title: 'My Poem' });
expect(result).toEqual({
messages: [
{
role: 'user',
content: {
type: 'text',
text: 'Write a haiku called My Poem. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables ',
},
},
],
});
});
it('should generate a prompt with a title and mood', () => {
const promptFn = mockRegisterPrompt.mock.calls[0][2];
const result = promptFn({ title: 'My Poem', mood: 'sad' });
expect(result).toEqual({
messages: [
{
role: 'user',
content: {
type: 'text',
text: 'Write a haiku with the mood sad called My Poem. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables ',
},
},
],
});
});
});
});
@@ -4,7 +4,7 @@
"mcpServers": {
"nodeServer": {
"command": "node",
"args": ["${extensionPath}${/}dist${/}example.js"],
"args": ["${extensionPath}${/}example.js"],
"cwd": "${extensionPath}"
}
}
@@ -4,13 +4,6 @@
"description": "Example MCP Server for Gemini CLI Extension",
"type": "module",
"main": "example.js",
"scripts": {
"build": "tsc"
},
"devDependencies": {
"typescript": "~5.4.5",
"@types/node": "^20.11.25"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
"zod": "^3.22.4"
@@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist"
},
"include": ["example.ts"]
}
@@ -0,0 +1,4 @@
{
"name": "skills-example",
"version": "1.0.0"
}
@@ -0,0 +1,7 @@
---
name: greeter
description: A friendly greeter skill
---
You are a friendly greeter. When the user says "hello" or asks for a greeting,
you should reply with: "Greetings from the skills-example extension! 👋"
@@ -511,8 +511,5 @@ 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,7 +230,10 @@ 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)
@@ -241,9 +244,6 @@ 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)}`);
}
+4 -3
View File
@@ -10,6 +10,7 @@ import { addCommand } from './mcp/add.js';
import { removeCommand } from './mcp/remove.js';
import { listCommand } from './mcp/list.js';
import { initializeOutputListenersAndFlush } from '../gemini.js';
import { defer } from '../deferred.js';
export const mcpCommand: CommandModule = {
command: 'mcp',
@@ -17,9 +18,9 @@ export const mcpCommand: CommandModule = {
builder: (yargs: Argv) =>
yargs
.middleware(() => initializeOutputListenersAndFlush())
.command(addCommand)
.command(removeCommand)
.command(listCommand)
.command(defer(addCommand, 'mcp'))
.command(defer(removeCommand, 'mcp'))
.command(defer(listCommand, 'mcp'))
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {
+16 -1
View File
@@ -123,8 +123,13 @@ describe('mcp list command', () => {
...defaultMergedSettings,
mcpServers: {
'stdio-server': { command: '/path/to/server', args: ['arg1'] },
'sse-server': { url: 'https://example.com/sse' },
'sse-server': { url: 'https://example.com/sse', type: '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',
},
},
},
});
@@ -150,6 +155,16 @@ 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 () => {
+2 -1
View File
@@ -144,7 +144,8 @@ export async function listMcpServers(): Promise<void> {
if (server.httpUrl) {
serverInfo += `${server.httpUrl} (http)`;
} else if (server.url) {
serverInfo += `${server.url} (sse)`;
const type = server.type || 'http';
serverInfo += `${server.url} (${type})`;
} else if (server.command) {
serverInfo += `${server.command} ${server.args?.join(' ') || ''} (stdio)`;
}
+13 -7
View File
@@ -38,13 +38,19 @@ describe('skillsCommand', () => {
skillsCommand.builder(mockYargs);
expect(mockYargs.middleware).toHaveBeenCalled();
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'list' });
expect(mockYargs.command).toHaveBeenCalledWith({
command: 'enable <name>',
});
expect(mockYargs.command).toHaveBeenCalledWith({
command: 'disable <name>',
});
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'list' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({
command: 'enable <name>',
}),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({
command: 'disable <name>',
}),
);
expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String));
expect(mockYargs.version).toHaveBeenCalledWith(false);
});
+6 -5
View File
@@ -11,6 +11,7 @@ import { disableCommand } from './skills/disable.js';
import { installCommand } from './skills/install.js';
import { uninstallCommand } from './skills/uninstall.js';
import { initializeOutputListenersAndFlush } from '../gemini.js';
import { defer } from '../deferred.js';
export const skillsCommand: CommandModule = {
command: 'skills <command>',
@@ -19,11 +20,11 @@ export const skillsCommand: CommandModule = {
builder: (yargs) =>
yargs
.middleware(() => initializeOutputListenersAndFlush())
.command(listCommand)
.command(enableCommand)
.command(disableCommand)
.command(installCommand)
.command(uninstallCommand)
.command(defer(listCommand, 'skills'))
.command(defer(enableCommand, 'skills'))
.command(defer(disableCommand, 'skills'))
.command(defer(installCommand, 'skills'))
.command(defer(uninstallCommand, 'skills'))
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {
+4 -1
View File
@@ -451,6 +451,7 @@ export async function loadCliConfig(
workspaceDir: cwd,
enabledExtensionOverrides: argv.extensions,
eventEmitter: appEvents as EventEmitter<ExtensionEvents>,
clientVersion: await getVersion(),
});
await extensionManager.loadExtensions();
@@ -653,6 +654,7 @@ export async function loadCliConfig(
return new Config({
sessionId,
clientVersion: await getVersion(),
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: sandboxConfig,
targetDir: cwd,
@@ -761,9 +763,10 @@ export async function loadCliConfig(
// TODO: loading of hooks based on workspace trust
enableHooks:
(settings.tools?.enableHooks ?? true) &&
(settings.hooks?.enabled ?? false),
(settings.hooksConfig?.enabled ?? true),
enableHooksUI: settings.tools?.enableHooks ?? true,
hooks: settings.hooks || {},
disabledHooks: settings.hooksConfig?.disabled || [],
projectHooks: projectHooks || {},
onModelChange: (model: string) => saveModelChange(loadedSettings, model),
onReload: async () => {
+6 -1
View File
@@ -76,6 +76,7 @@ interface ExtensionManagerParams {
requestSetting: ((setting: ExtensionSetting) => Promise<string>) | null;
workspaceDir: string;
eventEmitter?: EventEmitter<ExtensionEvents>;
clientVersion?: string;
}
/**
@@ -105,6 +106,7 @@ export class ExtensionManager extends ExtensionLoader {
telemetry: options.settings.telemetry,
interactive: false,
sessionId: randomUUID(),
clientVersion: options.clientVersion ?? 'unknown',
targetDir: options.workspaceDir,
cwd: options.workspaceDir,
model: '',
@@ -611,7 +613,10 @@ 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.hooks.enabled) {
if (
this.settings.tools.enableHooks &&
this.settings.hooksConfig.enabled
) {
hooks = await this.loadExtensionHooks(effectiveExtensionPath, {
extensionPath: effectiveExtensionPath,
workspacePath: this.workspaceDir,
+4 -3
View File
@@ -815,6 +815,7 @@ describe('extension tests', () => {
fs.mkdirSync(hooksDir);
const hooksConfig = {
enabled: false,
hooks: {
BeforeTool: [
{
@@ -836,7 +837,7 @@ describe('extension tests', () => {
);
const settings = loadSettings(tempWorkspaceDir).merged;
settings.hooks.enabled = true;
settings.hooksConfig.enabled = true;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
@@ -867,11 +868,11 @@ describe('extension tests', () => {
fs.mkdirSync(hooksDir);
fs.writeFileSync(
path.join(hooksDir, 'hooks.json'),
JSON.stringify({ hooks: { BeforeTool: [] } }),
JSON.stringify({ hooks: { BeforeTool: [] }, enabled: false }),
);
const settings = loadSettings(tempWorkspaceDir).merged;
settings.hooks.enabled = false;
settings.hooksConfig.enabled = false;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
+21 -3
View File
@@ -73,9 +73,27 @@ describe('keyBindings config', () => {
expect(dialogNavDown).toContainEqual({ key: 'down', shift: false });
expect(dialogNavDown).toContainEqual({ key: 'j', shift: false });
// Verify physical home/end keys
expect(defaultKeyBindings[Command.HOME]).toContainEqual({ key: 'home' });
expect(defaultKeyBindings[Command.END]).toContainEqual({ key: 'end' });
// 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,
});
});
});
+16 -4
View File
@@ -117,8 +117,14 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.EXIT]: [{ key: 'd', ctrl: true }],
// Cursor Movement
[Command.HOME]: [{ key: 'a', ctrl: true }, { key: 'home' }],
[Command.END]: [{ key: 'e', ctrl: true }, { key: 'end' }],
[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.MOVE_UP]: [{ key: 'up', ctrl: false, command: false }],
[Command.MOVE_DOWN]: [{ key: 'down', ctrl: false, command: false }],
[Command.MOVE_LEFT]: [
@@ -162,8 +168,14 @@ export const defaultKeyBindings: KeyBindingConfig = {
// Scrolling
[Command.SCROLL_UP]: [{ key: 'up', shift: true }],
[Command.SCROLL_DOWN]: [{ key: 'down', shift: true }],
[Command.SCROLL_HOME]: [{ key: 'home' }],
[Command.SCROLL_END]: [{ key: 'end' }],
[Command.SCROLL_HOME]: [
{ key: 'home', ctrl: true },
{ key: 'home', shift: true },
],
[Command.SCROLL_END]: [
{ key: 'end', ctrl: true },
{ key: 'end', shift: true },
],
[Command.PAGE_UP]: [{ key: 'pageup' }],
[Command.PAGE_DOWN]: [{ key: 'pagedown' }],
+68
View File
@@ -1961,6 +1961,57 @@ 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', () => {
@@ -2200,6 +2251,23 @@ describe('Settings Loading and Merging', () => {
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true);
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
});
it('should set skills based on advancedFeaturesEnabled', () => {
const loadedSettings = loadSettings();
loadedSettings.setRemoteAdminSettings({
cliFeatureSetting: {
advancedFeaturesEnabled: true,
},
});
expect(loadedSettings.merged.admin.skills?.enabled).toBe(true);
loadedSettings.setRemoteAdminSettings({
cliFeatureSetting: {
advancedFeaturesEnabled: false,
},
});
expect(loadedSettings.merged.admin.skills?.enabled).toBe(false);
});
});
describe('getDefaultsFromSchema', () => {
+6
View File
@@ -363,6 +363,10 @@ export class LoadedSettings {
admin.extensions = { enabled: extensionsSetting.extensionsEnabled };
}
if (cliFeatureSetting?.advancedFeaturesEnabled !== undefined) {
admin.skills = { enabled: cliFeatureSetting.advancedFeaturesEnabled };
}
this._remoteAdminSettings = { admin };
this._merged = this.computeMergedSettings();
}
@@ -804,6 +808,8 @@ 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 hooks.notifications setting in schema', () => {
const setting = getSettingsSchema().hooks.properties.notifications;
it('should have hooksConfig.notifications setting in schema', () => {
const setting = getSettingsSchema().hooksConfig?.properties.notifications;
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('Advanced');
+15 -7
View File
@@ -1631,9 +1631,9 @@ const SETTINGS_SCHEMA = {
},
},
hooks: {
hooksConfig: {
type: 'object',
label: 'Hooks',
label: 'HooksConfig',
category: 'Advanced',
requiresRestart: false,
default: {},
@@ -1646,7 +1646,7 @@ const SETTINGS_SCHEMA = {
label: 'Enable Hooks',
category: 'Advanced',
requiresRestart: false,
default: false,
default: true,
description:
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
showInDialog: false,
@@ -1675,6 +1675,18 @@ 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',
@@ -2117,10 +2129,6 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
type: 'boolean',
description: 'Whether to enable the agent.',
},
disabled: {
type: 'boolean',
description: 'Whether to disable the agent.',
},
},
},
CustomTheme: {
+217
View File
@@ -0,0 +1,217 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
runDeferredCommand,
defer,
setDeferredCommand,
type DeferredCommand,
} from './deferred.js';
import { ExitCodes } from '@google/gemini-cli-core';
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
import type { MergedSettings } from './config/settings.js';
import type { MockInstance } from 'vitest';
const { mockRunExitCleanup, mockDebugLogger } = vi.hoisted(() => ({
mockRunExitCleanup: vi.fn(),
mockDebugLogger: {
log: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual('@google/gemini-cli-core');
return {
...actual,
debugLogger: mockDebugLogger,
};
});
vi.mock('./utils/cleanup.js', () => ({
runExitCleanup: mockRunExitCleanup,
}));
let mockExit: MockInstance;
describe('deferred', () => {
beforeEach(() => {
vi.clearAllMocks();
mockExit = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
setDeferredCommand(undefined as unknown as DeferredCommand); // Reset deferred command
});
const createMockSettings = (adminSettings: unknown = {}): MergedSettings =>
({
admin: adminSettings,
}) as unknown as MergedSettings;
describe('runDeferredCommand', () => {
it('should do nothing if no deferred command is set', async () => {
await runDeferredCommand(createMockSettings());
expect(mockDebugLogger.log).not.toHaveBeenCalled();
expect(mockDebugLogger.error).not.toHaveBeenCalled();
expect(mockExit).not.toHaveBeenCalled();
});
it('should execute the deferred command if enabled', async () => {
const mockHandler = vi.fn();
setDeferredCommand({
handler: mockHandler,
argv: { _: [], $0: 'gemini' } as ArgumentsCamelCase,
commandName: 'mcp',
});
const settings = createMockSettings({ mcp: { enabled: true } });
await runDeferredCommand(settings);
expect(mockHandler).toHaveBeenCalled();
expect(mockRunExitCleanup).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
});
it('should exit with FATAL_CONFIG_ERROR if MCP is disabled', async () => {
setDeferredCommand({
handler: vi.fn(),
argv: {} as ArgumentsCamelCase,
commandName: 'mcp',
});
const settings = createMockSettings({ mcp: { enabled: false } });
await runDeferredCommand(settings);
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Error: MCP is disabled by your admin.',
);
expect(mockRunExitCleanup).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
});
it('should exit with FATAL_CONFIG_ERROR if extensions are disabled', async () => {
setDeferredCommand({
handler: vi.fn(),
argv: {} as ArgumentsCamelCase,
commandName: 'extensions',
});
const settings = createMockSettings({ extensions: { enabled: false } });
await runDeferredCommand(settings);
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Error: Extensions are disabled by your admin.',
);
expect(mockRunExitCleanup).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
});
it('should exit with FATAL_CONFIG_ERROR if skills are disabled', async () => {
setDeferredCommand({
handler: vi.fn(),
argv: {} as ArgumentsCamelCase,
commandName: 'skills',
});
const settings = createMockSettings({ skills: { enabled: false } });
await runDeferredCommand(settings);
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Error: Agent skills are disabled by your admin.',
);
expect(mockRunExitCleanup).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
});
it('should execute if admin settings are undefined (default implicit enable)', async () => {
const mockHandler = vi.fn();
setDeferredCommand({
handler: mockHandler,
argv: {} as ArgumentsCamelCase,
commandName: 'mcp',
});
const settings = createMockSettings({}); // No admin settings
await runDeferredCommand(settings);
expect(mockHandler).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
});
});
describe('defer', () => {
it('should wrap a command module and defer execution', async () => {
const originalHandler = vi.fn();
const commandModule: CommandModule = {
command: 'test',
describe: 'test command',
handler: originalHandler,
};
const deferredModule = defer(commandModule);
expect(deferredModule.command).toBe(commandModule.command);
// Execute the wrapper handler
const argv = { _: [], $0: 'gemini' } as ArgumentsCamelCase;
await deferredModule.handler(argv);
// Should check that it set the deferred command, but didn't run original handler yet
expect(originalHandler).not.toHaveBeenCalled();
// Now manually run it to verify it captured correctly
await runDeferredCommand(createMockSettings());
expect(originalHandler).toHaveBeenCalledWith(argv);
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
});
it('should use parentCommandName if provided', async () => {
const commandModule: CommandModule = {
command: 'subcommand',
describe: 'sub command',
handler: vi.fn(),
};
const deferredModule = defer(commandModule, 'parent');
await deferredModule.handler({} as ArgumentsCamelCase);
const deferredMcp = defer(commandModule, 'mcp');
await deferredMcp.handler({} as ArgumentsCamelCase);
const mcpSettings = createMockSettings({ mcp: { enabled: false } });
await runDeferredCommand(mcpSettings);
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Error: MCP is disabled by your admin.',
);
});
it('should fallback to unknown if no parentCommandName is provided', async () => {
const mockHandler = vi.fn();
const commandModule: CommandModule = {
command: ['foo', 'infoo'],
describe: 'foo command',
handler: mockHandler,
};
const deferredModule = defer(commandModule);
await deferredModule.handler({} as ArgumentsCamelCase);
// Verify it runs even if all known commands are disabled,
// confirming it didn't capture 'mcp', 'extensions', or 'skills'
// and defaulted to 'unknown' (or something else safe).
const settings = createMockSettings({
mcp: { enabled: false },
extensions: { enabled: false },
skills: { enabled: false },
});
await runDeferredCommand(settings);
expect(mockHandler).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
});
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
import { debugLogger, ExitCodes } from '@google/gemini-cli-core';
import { runExitCleanup } from './utils/cleanup.js';
import type { MergedSettings } from './config/settings.js';
import process from 'node:process';
export interface DeferredCommand {
handler: (argv: ArgumentsCamelCase) => void | Promise<void>;
argv: ArgumentsCamelCase;
commandName: string;
}
let deferredCommand: DeferredCommand | undefined;
export function setDeferredCommand(command: DeferredCommand) {
deferredCommand = command;
}
export async function runDeferredCommand(settings: MergedSettings) {
if (!deferredCommand) {
return;
}
const adminSettings = settings.admin;
const commandName = deferredCommand.commandName;
if (commandName === 'mcp' && adminSettings?.mcp?.enabled === false) {
debugLogger.error('Error: MCP is disabled by your admin.');
await runExitCleanup();
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
}
if (
commandName === 'extensions' &&
adminSettings?.extensions?.enabled === false
) {
debugLogger.error('Error: Extensions are disabled by your admin.');
await runExitCleanup();
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
}
if (commandName === 'skills' && adminSettings?.skills?.enabled === false) {
debugLogger.error('Error: Agent skills are disabled by your admin.');
await runExitCleanup();
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
}
await deferredCommand.handler(deferredCommand.argv);
await runExitCleanup();
process.exit(ExitCodes.SUCCESS);
}
/**
* Wraps a command's handler to defer its execution.
* It stores the handler and arguments in a singleton `deferredCommand` variable.
*/
export function defer<T = object, U = object>(
commandModule: CommandModule<T, U>,
parentCommandName?: string,
): CommandModule<T, U> {
return {
...commandModule,
handler: (argv: ArgumentsCamelCase<U>) => {
setDeferredCommand({
handler: commandModule.handler as (
argv: ArgumentsCamelCase,
) => void | Promise<void>,
argv: argv as unknown as ArgumentsCamelCase,
commandName: parentCommandName || 'unknown',
});
},
};
}
+2
View File
@@ -1085,6 +1085,8 @@ 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({
+10 -2
View File
@@ -96,6 +96,7 @@ import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
import { profiler } from './ui/components/DebugProfiler.js';
import { runDeferredCommand } from './deferred.js';
const SLOW_RENDER_MS = 200;
@@ -372,6 +373,7 @@ 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
@@ -399,8 +401,7 @@ export async function main() {
}
} catch (err) {
debugLogger.error('Error authenticating:', err);
await runExitCleanup();
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
initialAuthFailed = true;
}
}
@@ -410,6 +411,9 @@ export async function main() {
settings.setRemoteAdminSettings(remoteAdminSettings);
}
// Run deferred command now that we have admin settings.
await runDeferredCommand(settings.merged);
// hop into sandbox if we are outside and sandboxing is enabled
if (!process.env['SANDBOX']) {
const memoryArgs = settings.merged.advanced.autoConfigureMemory
@@ -423,6 +427,10 @@ 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,6 +166,7 @@ const mockUIActions: UIActions = {
handleFinalSubmit: vi.fn(),
handleClearScreen: vi.fn(),
handleProQuotaChoice: vi.fn(),
handleValidationChoice: vi.fn(),
setQueueErrorMessage: vi.fn(),
popAllMessages: vi.fn(),
handleApiKeySubmit: vi.fn(),
+11 -1
View File
@@ -495,7 +495,12 @@ export const AppContainer = (props: AppContainerProps) => {
}
}, [authState, authContext, setAuthState]);
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
const {
proQuotaRequest,
handleProQuotaChoice,
validationRequest,
handleValidationChoice,
} = useQuotaAndFallback({
config,
historyManager,
userTier,
@@ -1471,6 +1476,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
showPrivacyNotice ||
showIdeRestartPrompt ||
!!proQuotaRequest ||
!!validationRequest ||
isSessionBrowserOpen ||
isAuthDialogOpen ||
authState === AuthState.AwaitingApiKeyInput;
@@ -1588,6 +1594,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
currentModel,
userTier,
proQuotaRequest,
validationRequest,
contextFileNames,
errorCount,
availableTerminalHeight,
@@ -1678,6 +1685,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
showAutoAcceptIndicator,
userTier,
proQuotaRequest,
validationRequest,
contextFileNames,
errorCount,
availableTerminalHeight,
@@ -1747,6 +1755,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleFinalSubmit,
handleClearScreen,
handleProQuotaChoice,
handleValidationChoice,
openSessionBrowser,
closeSessionBrowser,
handleResumeSession,
@@ -1787,6 +1796,7 @@ 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'] = {
disabled: true,
enabled: false,
};
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'] = {
disabled: true,
enabled: false,
};
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]?.disabled === true,
(name) => overrides[name]?.enabled === false,
);
if (allAgents.includes(agentName)) {
if (allAgents.includes(agentName) && !disabledAgents.includes(agentName)) {
return {
type: 'message',
messageType: 'info',
@@ -96,7 +96,7 @@ async function enableAction(
};
}
if (!disabledAgents.includes(agentName)) {
if (!disabledAgents.includes(agentName) && !allAgents.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]?.disabled === true,
(name) => overrides[name]?.enabled === false,
);
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?.disabled === true)
.filter(([_, override]) => override?.enabled === false)
.map(([name]) => name);
return disabledAgents.filter((name) => name.startsWith(partialArg));
@@ -26,7 +26,7 @@ describe('hooksCommand', () => {
};
let mockSettings: {
merged: {
hooks?: {
hooksConfig?: {
disabled?: string[];
};
tools?: {
@@ -58,7 +58,7 @@ describe('hooksCommand', () => {
// Create mock settings
mockSettings = {
merged: {
hooks: {
hooksConfig: {
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.hooks.disabled = [
mockContext.services.settings.merged.hooksConfig.disabled = [
'test-hook',
'other-hook',
];
@@ -289,7 +289,7 @@ describe('hooksCommand', () => {
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
expect.any(String),
'hooks.disabled',
'hooksConfig.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.hooks.disabled = [];
mockContext.services.settings.merged.hooksConfig.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),
'hooks.disabled',
'hooksConfig.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.hooks.disabled = ['test-hook'];
mockContext.services.settings.merged.hooksConfig.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.hooks.disabled = [];
mockContext.services.settings.merged.hooksConfig.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),
'hooks.disabled',
'hooksConfig.disabled',
[],
);
expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith(
@@ -761,7 +761,7 @@ describe('hooksCommand', () => {
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
expect.any(String),
'hooks.disabled',
'hooksConfig.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.hooks.disabled;
const disabledHooks = settings.merged.hooksConfig.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, 'hooks.disabled', newDisabledHooks);
settings.setValue(scope, 'hooksConfig.disabled', newDisabledHooks);
// Update core config so re-initialization (e.g. extension reload) respects the change
config.updateDisabledHooks(settings.merged.hooks.disabled);
config.updateDisabledHooks(settings.merged.hooksConfig.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.hooks.disabled;
const disabledHooks = settings.merged.hooksConfig.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, 'hooks.disabled', newDisabledHooks);
settings.setValue(scope, 'hooksConfig.disabled', newDisabledHooks);
}
// Update core config so re-initialization (e.g. extension reload) respects the change
config.updateDisabledHooks(settings.merged.hooks.disabled);
config.updateDisabledHooks(settings.merged.hooksConfig.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, 'hooks.disabled', []);
settings.setValue(scope, 'hooksConfig.disabled', []);
// Update core config so re-initialization (e.g. extension reload) respects the change
config.updateDisabledHooks(settings.merged.hooks.disabled);
config.updateDisabledHooks(settings.merged.hooksConfig.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, 'hooks.disabled', allHookNames);
settings.setValue(scope, 'hooksConfig.disabled', allHookNames);
// Update core config so re-initialization (e.g. extension reload) respects the change
config.updateDisabledHooks(settings.merged.hooks.disabled);
config.updateDisabledHooks(settings.merged.hooksConfig.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: '',
buffer: { text: '' },
inputWidth: 80,
suggestionsWidth: 40,
userMessages: [],
@@ -389,6 +389,7 @@ 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,6 +6,7 @@
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,
@@ -16,6 +17,7 @@ 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(),
@@ -266,4 +268,40 @@ 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 { debugLogger } from '@google/gemini-cli-core';
import { coreEvents, CoreEvent, debugLogger } from '@google/gemini-cli-core';
// Frames that render at least this far before or after an action are considered
// idle frames.
@@ -160,9 +160,29 @@ 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,6 +17,7 @@ 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';
@@ -68,6 +69,16 @@ 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,10 +1893,35 @@ describe('InputPrompt', () => {
unmount();
});
it('should submit /rewind on double ESC', async () => {
it('should submit /rewind on double ESC when buffer is empty', async () => {
const onEscapePromptChange = vi.fn();
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('');
vi.mocked(props.buffer.setText).mockClear();
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiState: {
history: [{ id: 1, type: 'user', text: 'test' }],
},
},
);
await act(async () => {
stdin.write('\x1B\x1B');
vi.advanceTimersByTime(100);
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
});
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} />,
@@ -1906,7 +1931,8 @@ describe('InputPrompt', () => {
stdin.write('\x1B\x1B');
vi.advanceTimersByTime(100);
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
expect(props.buffer.setText).toHaveBeenCalledWith('');
expect(props.onSubmit).not.toHaveBeenCalledWith('/rewind');
});
unmount();
});
+12 -4
View File
@@ -138,7 +138,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState();
const { setEmbeddedShellFocused } = useUIActions();
const { mainAreaWidth, activePtyId } = useUIState();
const { mainAreaWidth, activePtyId, history } = 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 for rewind
// Handle double ESC
if (escPressCount.current === 0) {
escPressCount.current = 1;
setShowEscapePrompt(true);
@@ -506,9 +506,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetEscapeState();
}, 500);
} else {
// Second ESC triggers rewind
// Second ESC
resetEscapeState();
onSubmit('/rewind');
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
} else {
if (history.length > 0) {
onSubmit('/rewind');
}
}
}
return;
}
@@ -880,6 +887,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onSubmit,
activePtyId,
setEmbeddedShellFocused,
history,
],
);
@@ -11,6 +11,7 @@ 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', () => ({
@@ -23,8 +24,13 @@ 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: Partial<UIState> = {}): UIState =>
const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
({
ctrlCPressedOnce: false,
warningMessage: null,
@@ -35,6 +41,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
ideContextState: null,
geminiMdFileCount: 0,
contextFileNames: [],
buffer: { text: '' },
history: [{ id: 1, type: 'user', text: 'test' }],
...overrides,
}) as UIState;
@@ -52,7 +60,7 @@ const createMockConfig = (overrides = {}) => ({
const createMockSettings = (merged = {}) => ({
merged: {
hooks: { notifications: true },
hooksConfig: { notifications: true },
ui: { hideContextSummary: false },
...merged,
},
@@ -147,9 +155,22 @@ describe('StatusDisplay', () => {
expect(lastFrame()).toMatchSnapshot();
});
it('renders Escape prompt', () => {
it('renders Escape prompt when buffer is empty', () => {
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 },
@@ -185,7 +206,7 @@ describe('StatusDisplay', () => {
activeHooks: [{ name: 'hook', eventName: 'event' }],
});
const settings = createMockSettings({
hooks: { notifications: false },
hooksConfig: { notifications: false },
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
@@ -45,14 +45,28 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
}
if (uiState.showEscapePrompt) {
return <Text color={theme.text.secondary}>Press Esc again to rewind.</Text>;
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>
);
}
if (uiState.queueErrorMessage) {
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
}
if (uiState.activeHooks.length > 0 && settings.merged.hooks.notifications) {
if (
uiState.activeHooks.length > 0 &&
settings.merged.hooksConfig.notifications
) {
return <HookStatusDisplay activeHooks={uiState.activeHooks} />;
}
@@ -0,0 +1,195 @@
/**
* @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();
});
});
});
@@ -0,0 +1,177 @@
/**
* @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,7 +10,9 @@ 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 1`] = `"Press Esc again to rewind."`;
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 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[F
// End -> \x1b[1;5F (Ctrl+End)
await act(async () => {
stdin.write('\x1b[F');
stdin.write('\x1b[1;5F');
});
await waitFor(() => {
// Total 50 items, height 10. Max scroll ~40.
expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThan(30);
});
// Home -> \x1b[H
// Home -> \x1b[1;5H (Ctrl+Home)
await act(async () => {
stdin.write('\x1b[H');
stdin.write('\x1b[1;5H');
});
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(0);
@@ -1096,6 +1096,23 @@ 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,11 +2242,9 @@ 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' });
@@ -2292,6 +2290,7 @@ 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,6 +154,36 @@ 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', () => {
@@ -182,6 +212,9 @@ describe('KeypressContext', () => {
name: 'return',
sequence: '\r',
insertable: true,
shift: true,
meta: false,
ctrl: false,
}),
);
});
@@ -158,6 +158,9 @@ function bufferFastReturn(keypressHandler: KeypressHandler): KeypressHandler {
keypressHandler({
...key,
name: 'return',
shift: true, // to make it a newline, not a submission
ctrl: false,
meta: false,
sequence: '\r',
insertable: true,
});
@@ -524,9 +527,9 @@ function* emitKeys(
// carriage return
name = 'return';
meta = escaped;
} else if (ch === '\n') {
// Enter, should have been called linefeed
name = 'enter';
} else if (escaped && ch === '\n') {
// Alt+Enter (linefeed), should be consistent with carriage return
name = 'return';
meta = escaped;
} else if (ch === '\t') {
// tab
@@ -46,6 +46,7 @@ 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,6 +23,7 @@ import type {
UserTierId,
IdeInfo,
FallbackIntent,
ValidationIntent,
} from '@google/gemini-cli-core';
import type { DOMElement } from 'ink';
import type { SessionStatsState } from '../contexts/SessionContext.js';
@@ -38,6 +39,13 @@ 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';
@@ -102,6 +110,7 @@ export interface UIState {
// Quota-related state
userTier: UserTierId | undefined;
proQuotaRequest: ProQuotaDialogRequest | null;
validationRequest: ValidationDialogRequest | null;
currentModel: string;
contextFileNames: string[];
errorCount: number;
@@ -28,6 +28,7 @@ import {
processRestorableToolCalls,
recordToolCallInteractions,
ToolErrorType,
ValidationRequiredError,
coreEvents,
CoreEvent,
MCPDiscoveryState,
@@ -1100,6 +1101,12 @@ 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,4 +498,186 @@ 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,6 +9,8 @@ import {
type Config,
type FallbackModelHandler,
type FallbackIntent,
type ValidationHandler,
type ValidationIntent,
TerminalQuotaError,
ModelNotFoundError,
type UserTierId,
@@ -19,7 +21,10 @@ import {
import { useCallback, useEffect, useRef, useState } from 'react';
import { type UseHistoryManagerReturn } from './useHistoryManager.js';
import { MessageType } from '../types.js';
import { type ProQuotaDialogRequest } from '../contexts/UIStateContext.js';
import {
type ProQuotaDialogRequest,
type ValidationDialogRequest,
} from '../contexts/UIStateContext.js';
interface UseQuotaAndFallbackArgs {
config: Config;
@@ -36,7 +41,10 @@ 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(() => {
@@ -120,6 +128,36 @@ 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;
@@ -148,9 +186,35 @@ 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,
};
}
+6 -4
View File
@@ -43,6 +43,7 @@ describe('keyMatchers', () => {
createKey('a'),
createKey('a', { shift: true }),
createKey('b', { ctrl: true }),
createKey('home', { ctrl: true }),
],
},
{
@@ -52,6 +53,7 @@ describe('keyMatchers', () => {
createKey('e'),
createKey('e', { shift: true }),
createKey('a', { ctrl: true }),
createKey('end', { ctrl: true }),
],
},
{
@@ -157,13 +159,13 @@ describe('keyMatchers', () => {
},
{
command: Command.SCROLL_HOME,
positive: [createKey('home')],
negative: [createKey('end')],
positive: [createKey('home', { ctrl: true })],
negative: [createKey('end'), createKey('home')],
},
{
command: Command.SCROLL_END,
positive: [createKey('end')],
negative: [createKey('home')],
positive: [createKey('end', { ctrl: true })],
negative: [createKey('home'), createKey('end')],
},
{
command: Command.PAGE_UP,
+11 -12
View File
@@ -29,8 +29,8 @@ export interface AgentActionResult {
}
/**
* 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`.
* Enables an agent by ensuring it is enabled in any writable scope (User and Workspace).
* It sets `agents.overrides.<agentName>.enabled` to `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 isDisabled = agentOverrides?.[agentName]?.disabled === true;
const isEnabled = agentOverrides?.[agentName]?.enabled === true;
if (isDisabled) {
if (!isEnabled) {
foundInDisabledScopes.push({ scope, path: scopePath });
} else {
alreadyEnabledScopes.push({ scope, path: scopePath });
@@ -68,9 +68,8 @@ export function enableAgent(
const modifiedScopes: ModifiedScope[] = [];
for (const { scope, path } of foundInDisabledScopes) {
if (isLoadableSettingScope(scope)) {
// 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);
// Explicitly enable it.
settings.setValue(scope, `agents.overrides.${agentName}.enabled`, true);
modifiedScopes.push({ scope, path });
}
}
@@ -85,7 +84,7 @@ export function enableAgent(
}
/**
* Disables an agent by setting `agents.overrides.<agentName>.disabled` to `true` in the specified scope.
* Disables an agent by setting `agents.overrides.<agentName>.enabled` to `false` in the specified scope.
*/
export function disableAgent(
settings: LoadedSettings,
@@ -105,9 +104,9 @@ export function disableAgent(
const scopePath = settings.forScope(scope).path;
const agentOverrides = settings.forScope(scope).settings.agents?.overrides;
const isDisabled = agentOverrides?.[agentName]?.disabled === true;
const isEnabled = agentOverrides?.[agentName]?.enabled !== false;
if (isDisabled) {
if (!isEnabled) {
return {
status: 'no-op',
agentName,
@@ -127,7 +126,7 @@ export function disableAgent(
if (isLoadableSettingScope(otherScope)) {
const otherOverrides =
settings.forScope(otherScope).settings.agents?.overrides;
if (otherOverrides?.[agentName]?.disabled === true) {
if (otherOverrides?.[agentName]?.enabled === false) {
alreadyDisabledInOther.push({
scope: otherScope,
path: settings.forScope(otherScope).path,
@@ -135,7 +134,7 @@ export function disableAgent(
}
}
settings.setValue(scope, `agents.overrides.${agentName}.disabled`, true);
settings.setValue(scope, `agents.overrides.${agentName}.enabled`, false);
return {
status: 'success',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0-preview.3",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -5,7 +5,10 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DelegateToAgentTool } from './delegate-to-agent-tool.js';
import {
DelegateToAgentTool,
type DelegateParams,
} from './delegate-to-agent-tool.js';
import { AgentRegistry } from './registry.js';
import type { Config } from '../config/config.js';
import type { AgentDefinition } from './types.js';
@@ -110,14 +113,17 @@ describe('DelegateToAgentTool', () => {
);
});
it('should validate agent_name exists in registry', async () => {
// Zod validation happens at build time now (or rather, build validates the schema)
// Since we use discriminated union, an invalid agent_name won't match any option.
expect(() =>
tool.build({
agent_name: 'non_existent_agent',
}),
).toThrow();
it('should throw helpful error when agent_name does not exist', async () => {
// We allow validation to pass now, checking happens in execute.
const invocation = tool.build({
agent_name: 'non_existent_agent',
} as DelegateParams);
await expect(() =>
invocation.execute(new AbortController().signal),
).rejects.toThrow(
"Agent 'non_existent_agent' not found. Available agents are: 'test_agent' (A test agent), 'remote_agent' (A remote agent). Please choose a valid agent_name.",
);
});
it('should validate correct arguments', async () => {
@@ -138,24 +144,30 @@ describe('DelegateToAgentTool', () => {
);
});
it('should throw error for missing required argument', async () => {
// Missing arg1 should fail Zod validation
expect(() =>
tool.build({
agent_name: 'test_agent',
arg2: 123,
}),
).toThrow();
it('should throw helpful error for missing required argument', async () => {
const invocation = tool.build({
agent_name: 'test_agent',
arg2: 123,
} as DelegateParams);
await expect(() =>
invocation.execute(new AbortController().signal),
).rejects.toThrow(
"arg1: Required. Expected inputs: 'arg1' (required string), 'arg2' (optional number).",
);
});
it('should throw error for invalid argument type', async () => {
// arg1 should be string, passing number
expect(() =>
tool.build({
agent_name: 'test_agent',
arg1: 123,
}),
).toThrow();
it('should throw helpful error for invalid argument type', async () => {
const invocation = tool.build({
agent_name: 'test_agent',
arg1: 123,
} as DelegateParams);
await expect(() =>
invocation.execute(new AbortController().signal),
).rejects.toThrow(
"arg1: Expected string, received number. Expected inputs: 'arg1' (required string), 'arg2' (optional number).",
);
});
it('should allow optional arguments to be omitted', async () => {
@@ -22,7 +22,7 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { AgentDefinition, AgentInputs } from './types.js';
import { SubagentToolWrapper } from './subagent-tool-wrapper.js';
type DelegateParams = { agent_name: string } & Record<string, unknown>;
export type DelegateParams = { agent_name: string } & Record<string, unknown>;
export class DelegateToAgentTool extends BaseDeclarativeTool<
DelegateParams,
@@ -126,6 +126,14 @@ export class DelegateToAgentTool extends BaseDeclarativeTool<
);
}
override validateToolParams(_params: DelegateParams): string | null {
// We override the default schema validation because the generic JSON schema validation
// produces poor error messages for discriminated unions (anyOf).
// Instead, we perform detailed, agent-specific validation in the `execute` method
// to provide rich error messages that help the LLM self-heal.
return null;
}
protected createInvocation(
params: DelegateParams,
messageBus: MessageBus,
@@ -190,12 +198,79 @@ class DelegateInvocation extends BaseToolInvocation<
): Promise<ToolResult> {
const definition = this.registry.getDefinition(this.params.agent_name);
if (!definition) {
const availableAgents = this.registry
.getAllDefinitions()
.map((def) => `'${def.name}' (${def.description})`)
.join(', ');
throw new Error(
`Agent '${this.params.agent_name}' not found in registry.`,
`Agent '${this.params.agent_name}' not found. Available agents are: ${availableAgents}. Please choose a valid agent_name.`,
);
}
const { agent_name: _agent_name, ...agentArgs } = this.params;
// Validate specific agent arguments here using Zod to generate helpful error messages.
const inputShape: Record<string, z.ZodTypeAny> = {};
for (const [key, inputDef] of Object.entries(
definition.inputConfig.inputs,
)) {
let validator: z.ZodTypeAny;
switch (inputDef.type) {
case 'string':
validator = z.string();
break;
case 'number':
validator = z.number();
break;
case 'boolean':
validator = z.boolean();
break;
case 'integer':
validator = z.number().int();
break;
case 'string[]':
validator = z.array(z.string());
break;
case 'number[]':
validator = z.array(z.number());
break;
default:
validator = z.unknown();
}
if (!inputDef.required) {
validator = validator.optional();
}
inputShape[key] = validator.describe(inputDef.description);
}
const agentSchema = z.object(inputShape);
try {
agentSchema.parse(agentArgs);
} catch (e) {
if (e instanceof z.ZodError) {
const errorMessages = e.errors.map(
(err) => `${err.path.join('.')}: ${err.message}`,
);
const expectedInputs = Object.entries(definition.inputConfig.inputs)
.map(
([key, input]) =>
`'${key}' (${input.required ? 'required' : 'optional'} ${input.type})`,
)
.join(', ');
throw new Error(
`${errorMessages.join(', ')}. Expected inputs: ${expectedInputs}.`,
);
}
throw e;
}
const invocation = this.buildSubInvocation(
definition,
agentArgs as AgentInputs,
+3 -3
View File
@@ -309,7 +309,7 @@ describe('AgentRegistry', () => {
const config = makeMockedConfig({
agents: {
overrides: {
generalist: { enabled: true, disabled: true },
generalist: { enabled: false },
},
},
});
@@ -704,7 +704,7 @@ describe('AgentRegistry', () => {
const config = makeMockedConfig({
agents: {
overrides: {
MockAgent: { disabled: true },
MockAgent: { enabled: false },
},
},
});
@@ -719,7 +719,7 @@ describe('AgentRegistry', () => {
const config = makeMockedConfig({
agents: {
overrides: {
RemoteAgent: { disabled: true },
RemoteAgent: { enabled: false },
},
},
});
+4 -8
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]?.disabled
agentsOverrides[CodebaseInvestigatorAgent.name]?.enabled !== false
) {
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]?.disabled
agentsOverrides[CliHelpAgent.name]?.enabled !== false
) {
this.registerLocalAgent(CliHelpAgent(this.config));
}
@@ -280,12 +280,8 @@ export class AgentRegistry {
const isExperimental = definition.experimental === true;
let isEnabled = !isExperimental;
if (overrides) {
if (overrides.disabled !== undefined) {
isEnabled = !overrides.disabled;
} else if (overrides.enabled !== undefined) {
isEnabled = overrides.enabled;
}
if (overrides && overrides.enabled !== undefined) {
isEnabled = overrides.enabled;
}
return isEnabled;
@@ -11,6 +11,7 @@ import type {
ModelPolicyStateMap,
} from './modelPolicy.js';
import {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
@@ -36,6 +37,13 @@ const DEFAULT_ACTIONS: ModelPolicyActionMap = {
unknown: 'prompt',
};
const SILENT_ACTIONS: ModelPolicyActionMap = {
terminal: 'silent',
transient: 'silent',
not_found: 'silent',
unknown: 'silent',
};
const DEFAULT_STATE: ModelPolicyStateMap = {
terminal: 'terminal',
transient: 'terminal',
@@ -53,6 +61,22 @@ const PREVIEW_CHAIN: ModelPolicyChain = [
definePolicy({ model: PREVIEW_GEMINI_FLASH_MODEL, isLastResort: true }),
];
const FLASH_LITE_CHAIN: ModelPolicyChain = [
definePolicy({
model: DEFAULT_GEMINI_FLASH_LITE_MODEL,
actions: SILENT_ACTIONS,
}),
definePolicy({
model: DEFAULT_GEMINI_FLASH_MODEL,
actions: SILENT_ACTIONS,
}),
definePolicy({
model: DEFAULT_GEMINI_MODEL,
isLastResort: true,
actions: SILENT_ACTIONS,
}),
];
/**
* Returns the default ordered model policy chain for the user.
*/
@@ -70,6 +94,10 @@ export function createSingleModelChain(model: string): ModelPolicyChain {
return [definePolicy({ model, isLastResort: true })];
}
export function getFlashLitePolicyChain(): ModelPolicyChain {
return cloneChain(FLASH_LITE_CHAIN);
}
/**
* Provides a default policy scaffold for models not present in the catalog.
*/
@@ -12,7 +12,10 @@ import {
} from './policyHelpers.js';
import { createDefaultPolicy } from './policyCatalog.js';
import type { Config } from '../config/config.js';
import { DEFAULT_GEMINI_MODEL_AUTO } from '../config/models.js';
import {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
} from '../config/models.js';
const createMockConfig = (overrides: Partial<Config> = {}): Config =>
({
@@ -53,6 +56,26 @@ describe('policyHelpers', () => {
expect(chain[1]?.model).toBe('gemini-2.5-flash');
});
it('uses auto chain when preferred model is auto', () => {
const config = createMockConfig({
getModel: () => 'gemini-2.5-pro',
});
const chain = resolvePolicyChain(config, DEFAULT_GEMINI_MODEL_AUTO);
expect(chain).toHaveLength(2);
expect(chain[0]?.model).toBe('gemini-2.5-pro');
expect(chain[1]?.model).toBe('gemini-2.5-flash');
});
it('uses auto chain when configured model is auto even if preferred is concrete', () => {
const config = createMockConfig({
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
});
const chain = resolvePolicyChain(config, 'gemini-2.5-pro');
expect(chain).toHaveLength(2);
expect(chain[0]?.model).toBe('gemini-2.5-pro');
expect(chain[1]?.model).toBe('gemini-2.5-flash');
});
it('starts chain from preferredModel when model is "auto"', () => {
const config = createMockConfig({
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
@@ -62,6 +85,28 @@ describe('policyHelpers', () => {
expect(chain[0]?.model).toBe('gemini-2.5-flash');
});
it('returns flash-lite chain when preferred model is flash-lite', () => {
const config = createMockConfig({
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
});
const chain = resolvePolicyChain(config, DEFAULT_GEMINI_FLASH_LITE_MODEL);
expect(chain).toHaveLength(3);
expect(chain[0]?.model).toBe('gemini-2.5-flash-lite');
expect(chain[1]?.model).toBe('gemini-2.5-flash');
expect(chain[2]?.model).toBe('gemini-2.5-pro');
});
it('returns flash-lite chain when configured model is flash-lite', () => {
const config = createMockConfig({
getModel: () => DEFAULT_GEMINI_FLASH_LITE_MODEL,
});
const chain = resolvePolicyChain(config);
expect(chain).toHaveLength(3);
expect(chain[0]?.model).toBe('gemini-2.5-flash-lite');
expect(chain[1]?.model).toBe('gemini-2.5-flash');
expect(chain[2]?.model).toBe('gemini-2.5-pro');
});
it('wraps around the chain when wrapsAround is true', () => {
const config = createMockConfig({
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
+18 -10
View File
@@ -17,11 +17,13 @@ import {
createDefaultPolicy,
createSingleModelChain,
getModelPolicyChain,
getFlashLitePolicyChain,
} from './policyCatalog.js';
import {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL_AUTO,
isAutoModel,
resolveModel,
} from '../config/models.js';
import type { ModelSelectionResult } from './modelAvailabilityService.js';
@@ -38,24 +40,30 @@ export function resolvePolicyChain(
): ModelPolicyChain {
const modelFromConfig =
preferredModel ?? config.getActiveModel?.() ?? config.getModel();
const configuredModel = config.getModel();
let chain;
const resolvedModel = resolveModel(modelFromConfig);
const isAutoPreferred = preferredModel ? isAutoModel(preferredModel) : false;
const isAutoConfigured = isAutoModel(configuredModel);
if (
config.getModel() === PREVIEW_GEMINI_MODEL_AUTO ||
config.getModel() === DEFAULT_GEMINI_MODEL_AUTO
) {
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
chain = getFlashLitePolicyChain();
} else if (isAutoPreferred || isAutoConfigured) {
const previewEnabled =
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
chain = getModelPolicyChain({
previewEnabled: config.getModel() === PREVIEW_GEMINI_MODEL_AUTO,
previewEnabled,
userTier: config.getUserTier(),
});
} else {
chain = createSingleModelChain(modelFromConfig);
}
const activeModel = resolveModel(modelFromConfig);
const activeIndex = chain.findIndex((policy) => policy.model === activeModel);
const activeIndex = chain.findIndex(
(policy) => policy.model === resolvedModel,
);
if (activeIndex !== -1) {
return wrapsAround
? [...chain.slice(activeIndex), ...chain.slice(0, activeIndex)]
@@ -64,7 +72,7 @@ export function resolvePolicyChain(
// If the user specified a model not in the default chain, we assume they want
// *only* that model. We do not fallback to the default chain.
return [createDefaultPolicy(activeModel, { isLastResort: true })];
return [createDefaultPolicy(resolvedModel, { isLastResort: true })];
}
/**
+1
View File
@@ -302,6 +302,7 @@ const ExtensionsSettingSchema = z.object({
const CliFeatureSettingSchema = z.object({
extensionsSetting: ExtensionsSettingSchema.optional(),
advancedFeaturesEnabled: z.boolean().optional(),
});
const McpSettingSchema = z.object({
+1 -1
View File
@@ -1841,7 +1841,7 @@ describe('Hooks configuration', () => {
debugMode: false,
model: 'test-model',
cwd: '.',
hooks: { disabled: ['initial-hook'] },
disabledHooks: ['initial-hook'],
};
it('updateDisabledHooks should update the disabled list', () => {
+73 -16
View File
@@ -64,8 +64,13 @@ import { logRipgrepFallback, logFlashFallback } from '../telemetry/loggers.js';
import {
RipgrepFallbackEvent,
FlashFallbackEvent,
ApprovalModeSwitchEvent,
ApprovalModeDurationEvent,
} from '../telemetry/types.js';
import type { FallbackModelHandler } from '../fallback/types.js';
import type {
FallbackModelHandler,
ValidationHandler,
} from '../fallback/types.js';
import { ModelAvailabilityService } from '../availability/modelAvailabilityService.js';
import { ModelRouterService } from '../routing/modelRouterService.js';
import { OutputFormat } from '../output/types.js';
@@ -105,6 +110,10 @@ import { debugLogger } from '../utils/debugLogger.js';
import { SkillManager, type SkillDefinition } from '../skills/skillManager.js';
import { startupProfiler } from '../telemetry/startupProfiler.js';
import type { AgentDefinition } from '../agents/types.js';
import {
logApprovalModeSwitch,
logApprovalModeDuration,
} from '../telemetry/loggers.js';
import { fetchAdminControls } from '../code_assist/admin/admin_controls.js';
export interface AccessibilitySettings {
@@ -171,7 +180,6 @@ export interface AgentRunConfig {
export interface AgentOverride {
modelConfig?: ModelConfig;
runConfig?: AgentRunConfig;
disabled?: boolean;
enabled?: boolean;
}
@@ -285,6 +293,7 @@ export interface SandboxConfig {
export interface ConfigParameters {
sessionId: string;
clientVersion?: string;
embeddingModel?: string;
sandbox?: SandboxConfig;
targetDir: string;
@@ -373,10 +382,9 @@ export interface ConfigParameters {
enableHooks?: boolean;
enableHooksUI?: boolean;
experiments?: Experiments;
hooks?: { [K in HookEventName]?: HookDefinition[] } & { disabled?: string[] };
projectHooks?: { [K in HookEventName]?: HookDefinition[] } & {
disabled?: string[];
};
hooks?: { [K in HookEventName]?: HookDefinition[] };
disabledHooks?: string[];
projectHooks?: { [K in HookEventName]?: HookDefinition[] };
previewFeatures?: boolean;
enableAgents?: boolean;
enableEventDrivenScheduler?: boolean;
@@ -410,6 +418,7 @@ export class Config {
private agentRegistry!: AgentRegistry;
private skillManager!: SkillManager;
private sessionId: string;
private clientVersion: string;
private fileSystemService: FileSystemService;
private contentGeneratorConfig!: ContentGeneratorConfig;
private contentGenerator!: ContentGenerator;
@@ -471,6 +480,7 @@ 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>
@@ -544,9 +554,11 @@ export class Config {
private terminalBackground: string | undefined = undefined;
private remoteAdminSettings: FetchAdminControlsResponse | undefined;
private latestApiRequest: GenerateContentParameters | undefined;
private lastModeSwitchTime: number = Date.now();
constructor(params: ConfigParameters) {
this.sessionId = params.sessionId;
this.clientVersion = params.clientVersion ?? 'unknown';
this.embeddingModel =
params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL;
this.fileSystemService = new StandardFileSystemService();
@@ -670,11 +682,8 @@ export class Config {
? false
: (params.useWriteTodos ?? true);
this.enableHooksUI = params.enableHooksUI ?? true;
this.enableHooks = params.enableHooks ?? false;
this.disabledHooks =
(params.hooks && 'disabled' in params.hooks
? params.hooks.disabled
: undefined) ?? [];
this.enableHooks = params.enableHooks ?? true;
this.disabledHooks = params.disabledHooks ?? [];
this.codebaseInvestigatorSettings = {
enabled: params.codebaseInvestigatorSettings?.enabled ?? true,
@@ -715,8 +724,7 @@ export class Config {
this.disableYoloMode = params.disableYoloMode ?? false;
if (params.hooks) {
const { disabled: _, ...restOfHooks } = params.hooks;
this.hooks = restOfHooks;
this.hooks = params.hooks;
}
if (params.projectHooks) {
this.projectHooks = params.projectHooks;
@@ -804,6 +812,7 @@ export class Config {
this.toolRegistry = await this.createToolRegistry();
discoverToolsHandle?.end();
this.mcpClientManager = new McpClientManager(
this.clientVersion,
this.toolRegistry,
this,
this.eventEmitter,
@@ -1061,6 +1070,14 @@ export class Config {
return this.fallbackModelHandler;
}
setValidationHandler(handler: ValidationHandler): void {
this.validationHandler = handler;
}
getValidationHandler(): ValidationHandler | undefined {
return this.validationHandler;
}
resetTurn(): void {
this.modelAvailabilityService.resetTurn();
}
@@ -1278,6 +1295,24 @@ export class Config {
return this.userMemory;
}
/**
* Refreshes the MCP context, including memory, tools, and system instructions.
*/
async refreshMcpContext(): Promise<void> {
if (this.experimentalJitContext && this.contextManager) {
await this.contextManager.refresh();
} else {
const { refreshServerHierarchicalMemory } = await import(
'../utils/memoryDiscovery.js'
);
await refreshServerHierarchicalMemory(this);
}
if (this.geminiClient?.isInitialized()) {
await this.geminiClient.setTools();
await this.geminiClient.updateSystemInstruction();
}
}
setUserMemory(newUserMemory: string): void {
this.userMemory = newUserMemory;
}
@@ -1330,9 +1365,32 @@ export class Config {
'Cannot enable privileged approval modes in an untrusted folder.',
);
}
const currentMode = this.getApprovalMode();
if (currentMode !== mode) {
this.logCurrentModeDuration(this.getApprovalMode());
logApprovalModeSwitch(
this,
new ApprovalModeSwitchEvent(currentMode, mode),
);
this.lastModeSwitchTime = Date.now();
}
this.policyEngine.setApprovalMode(mode);
}
/**
* Logs the duration of the current approval mode.
*/
logCurrentModeDuration(mode: ApprovalMode): void {
const now = Date.now();
const duration = now - this.lastModeSwitchTime;
logApprovalModeDuration(
this,
new ApprovalModeDurationEvent(mode, duration),
);
}
isYoloModeDisabled(): boolean {
return this.disableYoloMode || !this.isTrustedFolder();
}
@@ -1942,9 +2000,7 @@ export class Config {
/**
* Get project-specific hooks configuration
*/
getProjectHooks():
| ({ [K in HookEventName]?: HookDefinition[] } & { disabled?: string[] })
| undefined {
getProjectHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined {
return this.projectHooks;
}
@@ -2036,6 +2092,7 @@ export class Config {
* Disposes of resources and removes event listeners.
*/
async dispose(): Promise<void> {
this.logCurrentModeDuration(this.getApprovalMode());
coreEvents.off(CoreEvent.AgentsRefreshed, this.onAgentsRefreshed);
this.agentRegistry?.dispose();
this.geminiClient?.dispose();
+1
View File
@@ -51,6 +51,7 @@ 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
+11
View File
@@ -303,6 +303,17 @@ describe('Gemini Client (client.ts)', () => {
});
});
describe('resumeChat', () => {
it('should update telemetry token count when a chat is resumed', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'resumed message' }] },
];
await client.resumeChat(history);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalled();
});
});
describe('resetChat', () => {
it('should create a new chat session, clearing the old history', async () => {
// 1. Get the initial chat instance and add some history.
+23
View File
@@ -25,6 +25,7 @@ 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 {
@@ -269,6 +270,7 @@ export class GeminiClient {
resumedSessionData?: ResumedSessionData,
): Promise<void> {
this.chat = await this.startChat(history, resumedSessionData);
this.updateTelemetryTokenCount();
}
getChatRecordingService(): ChatRecordingService | undefined {
@@ -925,8 +927,29 @@ 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,
+20 -83
View File
@@ -22,24 +22,12 @@ import { AuthType } from './contentGenerator.js';
import { TerminalQuotaError } from '../utils/googleQuotaErrors.js';
import { type RetryOptions } from '../utils/retry.js';
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
import { HookSystem } from '../hooks/hookSystem.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import { createAvailabilityServiceMock } from '../availability/testUtils.js';
import type { ModelAvailabilityService } from '../availability/modelAvailabilityService.js';
import * as policyHelpers from '../availability/policyHelpers.js';
import { makeResolvedModelConfig } from '../services/modelConfigServiceTestUtils.js';
import {
fireBeforeModelHook,
fireAfterModelHook,
fireBeforeToolSelectionHook,
} from './geminiChatHookTriggers.js';
// Mock hook triggers
vi.mock('./geminiChatHookTriggers.js', () => ({
fireBeforeModelHook: vi.fn(),
fireAfterModelHook: vi.fn(),
fireBeforeToolSelectionHook: vi.fn().mockResolvedValue({}),
}));
import type { HookSystem } from '../hooks/hookSystem.js';
// Mock fs module to prevent actual file system operations during tests
const mockFileSystem = new Map<string, string>();
@@ -204,9 +192,7 @@ describe('GeminiChat', () => {
setSimulate429(false);
// Reset history for each test by creating a new instance
chat = new GeminiChat(mockConfig);
mockConfig.getHookSystem = vi
.fn()
.mockReturnValue(new HookSystem(mockConfig));
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
});
afterEach(() => {
@@ -1279,10 +1265,13 @@ describe('GeminiChat', () => {
expect(mockLogContentRetry).toHaveBeenCalledTimes(1);
expect(mockLogContentRetryFailure).toHaveBeenCalledTimes(1);
// History should NOT contain the failed user message,
// to prevent invalid content from breaking subsequent requests.
// History should still contain the user message.
const history = chat.getHistory();
expect(history.length).toBe(0);
expect(history.length).toBe(1);
expect(history[0]).toEqual({
role: 'user',
parts: [{ text: 'test' }],
});
});
describe('API error retry behavior', () => {
@@ -1344,60 +1333,6 @@ describe('GeminiChat', () => {
).toHaveBeenCalledTimes(1);
});
it('should remove function response AND preceding function call on 400 error', async () => {
// Set up history with a user message and model function call
const initialUserMessage: Content = {
role: 'user',
parts: [{ text: 'Call a tool for me' }],
};
const modelFunctionCall: Content = {
role: 'model',
parts: [{ functionCall: { name: 'test_tool', args: {} } }],
};
chat.addHistory(initialUserMessage);
chat.addHistory(modelFunctionCall);
// Verify initial history state
expect(chat.getHistory().length).toBe(2);
const error400 = new ApiError({ message: 'Bad Request', status: 400 });
vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue(
error400,
);
// Send a function response that will fail with 400
const functionResponse = [
{
functionResponse: {
name: 'test_tool',
response: { invalid: 'data' },
},
},
];
const stream = await chat.sendMessageStream(
{ model: 'gemini-2.5-flash' },
functionResponse,
'prompt-id-400-fn',
new AbortController().signal,
);
await expect(
(async () => {
for await (const _ of stream) {
/* consume stream */
}
})(),
).rejects.toThrow(error400);
// History should only contain the initial user message.
// Both the function response AND the model's function call should be removed
// to avoid a dangling function call state.
const history = chat.getHistory();
expect(history.length).toBe(1);
expect(history[0]).toEqual(initialUserMessage);
});
it('should retry on 429 Rate Limit errors', async () => {
const error429 = new ApiError({ message: 'Rate Limited', status: 429 });
@@ -2334,18 +2269,20 @@ describe('GeminiChat', () => {
});
describe('Hook execution control', () => {
let mockHookSystem: HookSystem;
beforeEach(() => {
vi.mocked(mockConfig.getEnableHooks).mockReturnValue(true);
// Default to allowing execution
vi.mocked(fireBeforeModelHook).mockResolvedValue({ blocked: false });
vi.mocked(fireAfterModelHook).mockResolvedValue({
response: {} as GenerateContentResponse,
});
vi.mocked(fireBeforeToolSelectionHook).mockResolvedValue({});
mockHookSystem = {
fireBeforeModelEvent: vi.fn().mockResolvedValue({ blocked: false }),
fireAfterModelEvent: vi.fn().mockResolvedValue({ response: {} }),
fireBeforeToolSelectionEvent: vi.fn().mockResolvedValue({}),
} as unknown as HookSystem;
mockConfig.getHookSystem = vi.fn().mockReturnValue(mockHookSystem);
});
it('should yield AGENT_EXECUTION_STOPPED when BeforeModel hook stops execution', async () => {
vi.mocked(fireBeforeModelHook).mockResolvedValue({
vi.mocked(mockHookSystem.fireBeforeModelEvent).mockResolvedValue({
blocked: true,
stopped: true,
reason: 'stopped by hook',
@@ -2375,7 +2312,7 @@ describe('GeminiChat', () => {
candidates: [{ content: { parts: [{ text: 'blocked' }] } }],
} as GenerateContentResponse;
vi.mocked(fireBeforeModelHook).mockResolvedValue({
vi.mocked(mockHookSystem.fireBeforeModelEvent).mockResolvedValue({
blocked: true,
reason: 'blocked by hook',
syntheticResponse,
@@ -2414,7 +2351,7 @@ describe('GeminiChat', () => {
})(),
);
vi.mocked(fireAfterModelHook).mockResolvedValue({
vi.mocked(mockHookSystem.fireAfterModelEvent).mockResolvedValue({
response: {} as GenerateContentResponse,
stopped: true,
reason: 'stopped by after hook',
@@ -2450,7 +2387,7 @@ describe('GeminiChat', () => {
})(),
);
vi.mocked(fireAfterModelHook).mockResolvedValue({
vi.mocked(mockHookSystem.fireAfterModelEvent).mockResolvedValue({
response,
blocked: true,
reason: 'blocked by after hook',
+35 -65
View File
@@ -19,6 +19,7 @@ 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,
@@ -41,10 +42,7 @@ import {
ContentRetryFailureEvent,
} from '../telemetry/types.js';
import { handleFallback } from '../fallback/handler.js';
import {
isFunctionResponse,
isFunctionCall,
} from '../utils/messageInspectors.js';
import { isFunctionResponse } from '../utils/messageInspectors.js';
import { partListUnionToString } from './geminiRequest.js';
import type { ModelConfigKey } from '../services/modelConfigService.js';
import { estimateTokenCountSync } from '../utils/tokenCalculation.js';
@@ -52,11 +50,6 @@ import {
applyModelSelection,
createAvailabilityContextProvider,
} from '../availability/policyHelpers.js';
import {
fireAfterModelHook,
fireBeforeModelHook,
fireBeforeToolSelectionHook,
} from './geminiChatHookTriggers.js';
import { coreEvents } from '../utils/events.js';
export enum StreamEventType {
@@ -382,6 +375,9 @@ export class GeminiChat {
return; // Stop the generator
}
if (isConnectionPhase) {
throw error;
}
lastError = error;
const isContentError = error instanceof InvalidStreamError;
const isRetryable = isRetryableError(
@@ -389,11 +385,6 @@ export class GeminiChat {
this.config.getRetryFetchErrors(),
);
if (isConnectionPhase && !isRetryable) {
this.popFailedUserContent();
throw error;
}
if (
(isContentError && isGemini2Model(model)) ||
(isRetryable && !signal.aborted)
@@ -434,7 +425,6 @@ export class GeminiChat {
new ContentRetryFailureEvent(maxAttempts, lastError.type, model),
);
}
this.popFailedUserContent();
throw lastError;
}
} finally {
@@ -513,39 +503,26 @@ export class GeminiChat {
? contentsForPreviewModel
: requestContents;
// Fire BeforeModel and BeforeToolSelection hooks if enabled
const hooksEnabled = this.config.getEnableHooks();
const messageBus = this.config.getMessageBus();
if (hooksEnabled && messageBus) {
// Fire BeforeModel hook
const beforeModelResult = await fireBeforeModelHook(messageBus, {
const hookSystem = this.config.getHookSystem();
if (hookSystem) {
const beforeModelResult = await hookSystem.fireBeforeModelEvent({
model: modelToUse,
config,
contents: contentsToUse,
});
// Check if hook requested to stop execution
if (beforeModelResult.stopped) {
throw new AgentExecutionStoppedError(
beforeModelResult.reason || 'Agent execution stopped by hook',
);
}
// Check if hook blocked the model call
if (beforeModelResult.blocked) {
// Return a synthetic response generator
const syntheticResponse = beforeModelResult.syntheticResponse;
if (syntheticResponse) {
// Ensure synthetic response has a finish reason to prevent InvalidStreamError
if (
syntheticResponse.candidates &&
syntheticResponse.candidates.length > 0
) {
for (const candidate of syntheticResponse.candidates) {
if (!candidate.finishReason) {
candidate.finishReason = FinishReason.STOP;
}
}
for (const candidate of syntheticResponse?.candidates ?? []) {
if (!candidate.finishReason) {
candidate.finishReason = FinishReason.STOP;
}
}
@@ -555,7 +532,6 @@ export class GeminiChat {
);
}
// Apply modifications from BeforeModel hook
if (beforeModelResult.modifiedConfig) {
Object.assign(config, beforeModelResult.modifiedConfig);
}
@@ -566,17 +542,13 @@ export class GeminiChat {
contentsToUse = beforeModelResult.modifiedContents as Content[];
}
// Fire BeforeToolSelection hook
const toolSelectionResult = await fireBeforeToolSelectionHook(
messageBus,
{
const toolSelectionResult =
await hookSystem.fireBeforeToolSelectionEvent({
model: modelToUse,
config,
contents: contentsToUse,
},
);
});
// Apply tool configuration modifications
if (toolSelectionResult.toolConfig) {
config.toolConfig = toolSelectionResult.toolConfig;
}
@@ -608,8 +580,24 @@ 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,
@@ -679,21 +667,6 @@ export class GeminiChat {
this.history = [];
}
/**
* Removes failed user content from history. If the content was a function
* response, also removes the preceding model function call to keep
* history consistent (avoids dangling function call state).
*/
private popFailedUserContent(): void {
const popped = this.history.pop();
if (popped && isFunctionResponse(popped)) {
const prev = this.history[this.history.length - 1];
if (prev && isFunctionCall(prev)) {
this.history.pop();
}
}
}
/**
* Adds a new entry to the chat history.
*/
@@ -846,12 +819,9 @@ export class GeminiChat {
}
}
// Fire AfterModel hook through MessageBus (only if hooks are enabled)
const hooksEnabled = this.config.getEnableHooks();
const messageBus = this.config.getMessageBus();
if (hooksEnabled && messageBus && originalRequest && chunk) {
const hookResult = await fireAfterModelHook(
messageBus,
const hookSystem = this.config.getHookSystem();
if (originalRequest && chunk && hookSystem) {
const hookResult = await hookSystem.fireAfterModelEvent(
originalRequest,
chunk,
);
@@ -871,7 +841,7 @@ export class GeminiChat {
yield hookResult.response;
} else {
yield chunk; // Yield every chunk to the UI immediately.
yield chunk;
}
}

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