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