mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 21:21:09 -07:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 393e9a171f | |||
| 59702f913d | |||
| b3439e1458 | |||
| b21b289e26 | |||
| 56a63a35e2 | |||
| 34f0c1538b | |||
| c70c95ead3 | |||
| f3bbe6e77a | |||
| 28e79831ac | |||
| fdf5b18cfb | |||
| b5f3eb2c9c | |||
| 2a84090dd5 | |||
| 27d7aeb1ed | |||
| d6c560498b | |||
| 4500da339b | |||
| 4be08a2261 | |||
| fe332bbef7 | |||
| 50af050623 | |||
| e5207eb67f | |||
| f15bcaf499 | |||
| 5f2f60bed6 | |||
| c332d1e636 | |||
| aa158e18d3 | |||
| fca29b0bd8 | |||
| 1e2afbb514 | |||
| 208291f391 | |||
| 8303edbb54 |
@@ -45,6 +45,10 @@ Write precisely to ensure your instructions are unambiguous.
|
||||
specific verbs.
|
||||
- **Examples:** Use meaningful names in examples; avoid placeholders like
|
||||
"foo" or "bar."
|
||||
- **Quota and limit terminology:** For any content involving resource capacity
|
||||
or using the word "quota" or "limit", strictly adhere to the guidelines in
|
||||
the `quota-limit-style-guide.md` resource file. Generally, Use "quota" for the
|
||||
administrative bucket and "limit" for the numerical ceiling.
|
||||
|
||||
### Formatting and syntax
|
||||
Apply consistent formatting to make documentation visually organized and
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Style Guide: Quota vs. Limit
|
||||
|
||||
This guide defines the usage of "quota," "limit," and related terms in
|
||||
user-facing interfaces.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **`quota`**: The administrative "bucket." Use for settings, billing, and
|
||||
requesting increases. (e.g., "Adjust your storage **quota**.")
|
||||
- **`limit`**: The real-time numerical "ceiling." Use for error messages when a
|
||||
user is blocked. (e.g., "You've reached your request **limit**.")
|
||||
- **When blocked, combine them:** Explain the **limit** that was hit and the
|
||||
**quota** that is the remedy. (e.g., "You've reached the request **limit** for
|
||||
your developer **quota**.")
|
||||
- **Related terms:** Use `usage` for consumption tracking, `restriction` for
|
||||
fixed rules, and `reset` for when a limit refreshes.
|
||||
|
||||
---
|
||||
|
||||
## Detailed Guidelines
|
||||
|
||||
### Definitions
|
||||
|
||||
- **Quota is the "what":** It identifies the category of resource being managed
|
||||
(e.g., storage quota, GPU quota, request/prompt quota).
|
||||
- **Limit is the "how much":** It defines the numerical boundary.
|
||||
|
||||
Use **quota** when referring to the administrative concept or the request for
|
||||
more. Use **limit** when discussing the specific point of exhaustion.
|
||||
|
||||
### When to use "quota"
|
||||
|
||||
Use this term for **account management, billing, and settings.** It describes
|
||||
the entitlement the user has purchased or been assigned.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- **Navigation label:** Quota and usage
|
||||
- **Contextual help:** Your **usage quota** is managed by your organization. To
|
||||
request an increase, contact your administrator.
|
||||
|
||||
### When to use "limit"
|
||||
|
||||
Use this term for **real-time feedback, notifications, and error messages.** It
|
||||
identifies the specific wall the user just hit.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- **Error message:** You’ve reached the 50-request-per-minute **limit**.
|
||||
- **Inline warning:** Input exceeds the 32k token **limit**.
|
||||
|
||||
### How to use both together
|
||||
|
||||
When a user is blocked, combine both terms to explain the **event** (limit) and
|
||||
the **remedy** (quota).
|
||||
|
||||
**Example:**
|
||||
|
||||
- **Heading:** Daily usage limit reached
|
||||
- **Body:** You've reached the maximum daily capacity for your developer quota.
|
||||
To continue working today, upgrade your quota.
|
||||
@@ -23,6 +23,10 @@ jobs:
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
env:
|
||||
APP_ID: '${{ secrets.APP_ID }}'
|
||||
if: |-
|
||||
${{ env.APP_ID != '' }}
|
||||
uses: 'actions/create-github-app-token@v2'
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
@@ -33,7 +37,7 @@ jobs:
|
||||
env:
|
||||
DRY_RUN: '${{ inputs.dry_run }}'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}'
|
||||
script: |
|
||||
const dryRun = process.env.DRY_RUN === 'true';
|
||||
const thirtyDaysAgo = new Date();
|
||||
|
||||
@@ -464,8 +464,9 @@ on GitHub.
|
||||
page in their default browser directly from the CLI using the `/extension`
|
||||
explore command. ([pr](https://github.com/google-gemini/gemini-cli/pull/11846)
|
||||
by [@JayadityaGit](https://github.com/JayadityaGit)).
|
||||
- **Configurable compression:** Users can modify the compression threshold in
|
||||
`/settings`. The default has been made more proactive
|
||||
- **Configurable compression:** Users can modify the context compression
|
||||
threshold in `/settings` (decimal with percentage display). The default has
|
||||
been made more proactive
|
||||
([pr](https://github.com/google-gemini/gemini-cli/pull/12317) by
|
||||
[@scidomino](https://github.com/scidomino)).
|
||||
- **API key authentication:** Users can now securely enter and store their
|
||||
|
||||
+35
-5
@@ -21,13 +21,15 @@ implementation. It allows you to:
|
||||
- [Entering Plan Mode](#entering-plan-mode)
|
||||
- [Planning Workflow](#planning-workflow)
|
||||
- [Exiting Plan Mode](#exiting-plan-mode)
|
||||
- [Commands](#commands)
|
||||
- [Tool Restrictions](#tool-restrictions)
|
||||
- [Customizing Planning with Skills](#customizing-planning-with-skills)
|
||||
- [Customizing Policies](#customizing-policies)
|
||||
- [Example: Allow git commands in Plan Mode](#example-allow-git-commands-in-plan-mode)
|
||||
- [Example: Enable research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode)
|
||||
- [Example: Enable custom subagents in Plan Mode](#example-enable-custom-subagents-in-plan-mode)
|
||||
- [Custom Plan Directory and Policies](#custom-plan-directory-and-policies)
|
||||
- [Automatic Model Routing](#automatic-model-routing)
|
||||
- [Cleanup](#cleanup)
|
||||
|
||||
## Enabling Plan Mode
|
||||
|
||||
@@ -125,6 +127,10 @@ To exit Plan Mode, you can:
|
||||
- **Tool:** Gemini CLI calls the [`exit_plan_mode`] tool to present the
|
||||
finalized plan for your approval.
|
||||
|
||||
### Commands
|
||||
|
||||
- **`/plan copy`**: Copy the currently approved plan to your clipboard.
|
||||
|
||||
## Tool Restrictions
|
||||
|
||||
Plan Mode enforces strict safety policies to prevent accidental changes.
|
||||
@@ -133,6 +139,7 @@ These are the only allowed tools:
|
||||
|
||||
- **FileSystem (Read):** [`read_file`], [`list_directory`], [`glob`]
|
||||
- **Search:** [`grep_search`], [`google_web_search`]
|
||||
- **Research Subagents:** [`codebase_investigator`], [`cli_help`]
|
||||
- **Interaction:** [`ask_user`]
|
||||
- **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`,
|
||||
`postgres_read_schema`) are allowed.
|
||||
@@ -203,16 +210,17 @@ priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
#### Example: Enable research subagents in Plan Mode
|
||||
#### Example: Enable custom subagents in Plan Mode
|
||||
|
||||
You can enable experimental research [subagents] like `codebase_investigator` to
|
||||
help gather architecture details during the planning phase.
|
||||
Built-in research [subagents] like [`codebase_investigator`] and [`cli_help`]
|
||||
are enabled by default in Plan Mode. You can enable additional [custom
|
||||
subagents] by adding a rule to your policy.
|
||||
|
||||
`~/.gemini/policies/research-subagents.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "codebase_investigator"
|
||||
toolName = "my_custom_subagent"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
@@ -290,6 +298,24 @@ performance. You can disable this automatic switching in your settings:
|
||||
}
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
By default, Gemini CLI automatically cleans up old session data, including all
|
||||
associated plan files and task trackers.
|
||||
|
||||
- **Default behavior:** Sessions (and their plans) are retained for **30 days**.
|
||||
- **Configuration:** You can customize this behavior via the `/settings` command
|
||||
(search for **Session Retention**) or in your `settings.json` file. See
|
||||
[session retention] for more details.
|
||||
|
||||
Manual deletion also removes all associated artifacts:
|
||||
|
||||
- **Command Line:** Use `gemini --delete-session <index|id>`.
|
||||
- **Session Browser:** Press `/resume`, navigate to a session, and press `x`.
|
||||
|
||||
If you use a [custom plans directory](#custom-plan-directory-and-policies),
|
||||
those files are not automatically deleted and must be managed manually.
|
||||
|
||||
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
|
||||
[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile
|
||||
[`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext
|
||||
@@ -300,7 +326,10 @@ performance. You can disable this automatic switching in your settings:
|
||||
[MCP tools]: /docs/tools/mcp-server.md
|
||||
[`save_memory`]: /docs/tools/memory.md
|
||||
[`activate_skill`]: /docs/cli/skills.md
|
||||
[`codebase_investigator`]: /docs/core/subagents.md#codebase_investigator
|
||||
[`cli_help`]: /docs/core/subagents.md#cli_help
|
||||
[subagents]: /docs/core/subagents.md
|
||||
[custom subagents]: /docs/core/subagents.md#creating-custom-subagents
|
||||
[policy engine]: /docs/reference/policy-engine.md
|
||||
[`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode
|
||||
[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode
|
||||
@@ -311,3 +340,4 @@ performance. You can disable this automatic switching in your settings:
|
||||
[auto model]: /docs/reference/configuration.md#model-settings
|
||||
[model routing]: /docs/cli/telemetry.md#model-routing
|
||||
[preferred external editor]: /docs/reference/configuration.md#general
|
||||
[session retention]: /docs/cli/session-management.md#session-retention
|
||||
|
||||
@@ -121,27 +121,36 @@ session lengths.
|
||||
|
||||
### Session retention
|
||||
|
||||
To prevent your history from growing indefinitely, enable automatic cleanup
|
||||
policies in your settings.
|
||||
By default, Gemini CLI automatically cleans up old session data to prevent your
|
||||
history from growing indefinitely. When a session is deleted, Gemini CLI also
|
||||
removes all associated data, including implementation plans, task trackers, tool
|
||||
outputs, and activity logs.
|
||||
|
||||
The default policy is to **retain sessions for 30 days**.
|
||||
|
||||
#### Configuration
|
||||
|
||||
You can customize these policies using the `/settings` command or by manually
|
||||
editing your `settings.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"general": {
|
||||
"sessionRetention": {
|
||||
"enabled": true,
|
||||
"maxAge": "30d", // Keep sessions for 30 days
|
||||
"maxCount": 50 // Keep the 50 most recent sessions
|
||||
"maxAge": "30d",
|
||||
"maxCount": 50
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`enabled`**: (boolean) Master switch for session cleanup. Defaults to
|
||||
`false`.
|
||||
`true`.
|
||||
- **`maxAge`**: (string) Duration to keep sessions (for example, "24h", "7d",
|
||||
"4w"). Sessions older than this are deleted.
|
||||
"4w"). Sessions older than this are deleted. Defaults to `"30d"`.
|
||||
- **`maxCount`**: (number) Maximum number of sessions to retain. The oldest
|
||||
sessions exceeding this count are deleted.
|
||||
sessions exceeding this count are deleted. Defaults to undefined (unlimited).
|
||||
- **`minRetention`**: (string) Minimum retention period (safety limit). Defaults
|
||||
to `"1d"`. Sessions newer than this period are never deleted by automatic
|
||||
cleanup.
|
||||
|
||||
@@ -60,7 +60,7 @@ they appear in the UI.
|
||||
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
|
||||
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
|
||||
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
|
||||
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` |
|
||||
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` |
|
||||
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
|
||||
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
|
||||
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
|
||||
@@ -89,13 +89,13 @@ they appear in the UI.
|
||||
|
||||
### Model
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- |
|
||||
| Model | `model.name` | The Gemini model to use for conversations. | `undefined` |
|
||||
| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
|
||||
| Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` |
|
||||
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
|
||||
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ----------------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- |
|
||||
| Model | `model.name` | The Gemini model to use for conversations. | `undefined` |
|
||||
| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
|
||||
| Context Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` |
|
||||
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
|
||||
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
|
||||
|
||||
### Context
|
||||
|
||||
|
||||
@@ -122,7 +122,10 @@ The manifest file defines the extension's behavior and configuration.
|
||||
}
|
||||
},
|
||||
"contextFileName": "GEMINI.md",
|
||||
"excludeTools": ["run_shell_command"]
|
||||
"excludeTools": ["run_shell_command"],
|
||||
"plan": {
|
||||
"directory": ".gemini/plans"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -157,6 +160,11 @@ The manifest file defines the extension's behavior and configuration.
|
||||
`"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf`
|
||||
command. Note that this differs from the MCP server `excludeTools`
|
||||
functionality, which can be listed in the MCP server config.
|
||||
- `plan`: Planning features configuration.
|
||||
- `directory`: The directory where planning artifacts are stored. This serves
|
||||
as a fallback if the user hasn't specified a plan directory in their
|
||||
settings. If not specified by either the extension or the user, the default
|
||||
is `~/.gemini/tmp/<project>/<session-id>/plans/`.
|
||||
|
||||
When Gemini CLI starts, it loads all the extensions and merges their
|
||||
configurations. If there are any conflicts, the workspace configuration takes
|
||||
|
||||
@@ -270,6 +270,9 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
one has been generated.
|
||||
- **Note:** This feature requires the `experimental.plan` setting to be
|
||||
enabled in your configuration.
|
||||
- **Sub-commands:**
|
||||
- **`copy`**:
|
||||
- **Description:** Copy the currently approved plan to your clipboard.
|
||||
|
||||
### `/policies`
|
||||
|
||||
|
||||
@@ -263,7 +263,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.footer.hideContextPercentage`** (boolean):
|
||||
- **Description:** Hides the context window remaining percentage.
|
||||
- **Description:** Hides the context window usage percentage.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.hideFooter`** (boolean):
|
||||
|
||||
+8
-1
@@ -94,7 +94,14 @@
|
||||
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
|
||||
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
|
||||
{ "label": "Headless mode", "slug": "docs/cli/headless" },
|
||||
{ "label": "Hooks", "slug": "docs/hooks" },
|
||||
{
|
||||
"label": "Hooks",
|
||||
"collapsed": true,
|
||||
"items": [
|
||||
{ "label": "Overview", "slug": "docs/hooks" },
|
||||
{ "label": "Reference", "slug": "docs/hooks/reference" }
|
||||
]
|
||||
},
|
||||
{ "label": "IDE integration", "slug": "docs/ide-integration" },
|
||||
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
|
||||
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
|
||||
|
||||
+35
-12
@@ -25,6 +25,18 @@ const __dirname = path.dirname(__filename);
|
||||
const projectRoot = __dirname;
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const commonRestrictedSyntaxRules = [
|
||||
{
|
||||
selector: 'CallExpression[callee.name="require"]',
|
||||
message: 'Avoid using require(). Use ES6 imports instead.',
|
||||
},
|
||||
{
|
||||
selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])',
|
||||
message:
|
||||
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
|
||||
},
|
||||
];
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
// Global ignores
|
||||
@@ -120,18 +132,7 @@ export default tseslint.config(
|
||||
'no-cond-assign': 'error',
|
||||
'no-debugger': 'error',
|
||||
'no-duplicate-case': 'error',
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'CallExpression[callee.name="require"]',
|
||||
message: 'Avoid using require(). Use ES6 imports instead.',
|
||||
},
|
||||
{
|
||||
selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])',
|
||||
message:
|
||||
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
|
||||
},
|
||||
],
|
||||
'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules],
|
||||
'no-unsafe-finally': 'error',
|
||||
'no-unused-expressions': 'off', // Disable base rule
|
||||
'@typescript-eslint/no-unused-expressions': [
|
||||
@@ -171,6 +172,28 @@ export default tseslint.config(
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// API Response Optionality enforcement for Code Assist
|
||||
files: ['packages/core/src/code_assist/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
...commonRestrictedSyntaxRules,
|
||||
{
|
||||
selector:
|
||||
'TSInterfaceDeclaration[id.name=/.+Response$/] TSPropertySignature:not([optional=true])',
|
||||
message:
|
||||
'All fields in API response interfaces (*Response) must be marked as optional (?) to prevent developers from accidentally assuming a field will always be present based on current backend behavior.',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
'TSTypeAliasDeclaration[id.name=/.+Response$/] TSPropertySignature:not([optional=true])',
|
||||
message:
|
||||
'All fields in API response types (*Response) must be marked as optional (?) to prevent developers from accidentally assuming a field will always be present based on current backend behavior.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Rules that only apply to product code
|
||||
files: ['packages/*/src/**/*.{ts,tsx}'],
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('ask_user', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'Agent uses AskUser tool to present multiple choice options',
|
||||
prompt: `Use the ask_user tool to ask me what my favorite color is. Provide 3 options: red, green, or blue.`,
|
||||
assert: async (rig) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('ask_user');
|
||||
expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'Agent uses AskUser tool to clarify ambiguous requirements',
|
||||
files: {
|
||||
'package.json': JSON.stringify({ name: 'my-app', version: '1.0.0' }),
|
||||
},
|
||||
prompt: `I want to build a new feature in this app. Ask me questions to clarify the requirements before proceeding.`,
|
||||
assert: async (rig) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('ask_user');
|
||||
expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'Agent uses AskUser tool before performing significant ambiguous rework',
|
||||
files: {
|
||||
'packages/core/src/index.ts': '// index\nexport const version = "1.0.0";',
|
||||
'packages/core/src/util.ts': '// util\nexport function help() {}',
|
||||
'packages/core/package.json': JSON.stringify({
|
||||
name: '@google/gemini-cli-core',
|
||||
}),
|
||||
'README.md': '# Gemini CLI',
|
||||
},
|
||||
prompt: `Refactor the entire core package to be better.`,
|
||||
assert: async (rig) => {
|
||||
const wasPlanModeCalled = await rig.waitForToolCall('enter_plan_mode');
|
||||
expect(wasPlanModeCalled, 'Expected enter_plan_mode to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const wasAskUserCalled = await rig.waitForToolCall('ask_user');
|
||||
expect(
|
||||
wasAskUserCalled,
|
||||
'Expected ask_user tool to be called to clarify the significant rework',
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
// --- Regression Tests for Recent Fixes ---
|
||||
|
||||
// Regression test for issue #20177: Ensure the agent does not use `ask_user` to
|
||||
// confirm shell commands. Fixed via prompt refinements and tool definition
|
||||
// updates to clarify that shell command confirmation is handled by the UI.
|
||||
// See fix: https://github.com/google-gemini/gemini-cli/pull/20504
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'Agent does NOT use AskUser to confirm shell commands',
|
||||
files: {
|
||||
'package.json': JSON.stringify({
|
||||
scripts: { build: 'echo building' },
|
||||
}),
|
||||
},
|
||||
prompt: `Run 'npm run build' in the current directory.`,
|
||||
assert: async (rig) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const wasShellCalled = toolLogs.some(
|
||||
(log) => log.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
const wasAskUserCalled = toolLogs.some(
|
||||
(log) => log.toolRequest.name === 'ask_user',
|
||||
);
|
||||
|
||||
expect(
|
||||
wasShellCalled,
|
||||
'Expected run_shell_command tool to be called',
|
||||
).toBe(true);
|
||||
expect(
|
||||
wasAskUserCalled,
|
||||
'ask_user should not be called to confirm shell commands',
|
||||
).toBe(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
Generated
+244
-71
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.33.0-nightly.20260228.1ca5c05d0",
|
||||
"version": "0.33.0-preview.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.33.0-nightly.20260228.1ca5c05d0",
|
||||
"version": "0.33.0-preview.2",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -5464,13 +5464,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"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
|
||||
},
|
||||
"node_modules/array-includes": {
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
|
||||
@@ -6305,16 +6298,36 @@
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/clipboardy": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-5.0.0.tgz",
|
||||
"integrity": "sha512-MQfKHaD09eP80Pev4qBxZLbxJK/ONnqfSYAPlCmPh+7BDboYtO/3BmB6HGzxDIT0SlTRc2tzS8lQqfcdLtZ0Kg==",
|
||||
"node_modules/clipboard-image": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/clipboard-image/-/clipboard-image-0.1.0.tgz",
|
||||
"integrity": "sha512-SWk7FgaXLNFld19peQ/rTe0n97lwR1WbkqxV6JKCAOh7U52AKV/PeMFCyt/8IhBdqyDA8rdyewQMKZqvWT5Akg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"execa": "^9.6.0",
|
||||
"run-jxa": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"clipboard-image": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/clipboardy": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-5.2.1.tgz",
|
||||
"integrity": "sha512-RWp4E/ivQAzgF4QSWA9sjeW+Bjo+U2SvebkDhNIfO7y65eGdXPUxMTdIKYsn+bxM3ItPHGm3e68Bv3fgQ3mARw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clipboard-image": "^0.1.0",
|
||||
"execa": "^9.6.1",
|
||||
"is-wayland": "^0.1.0",
|
||||
"is-wsl": "^3.1.0",
|
||||
"is64bit": "^2.0.0"
|
||||
"is64bit": "^2.0.0",
|
||||
"powershell-utils": "^0.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -6570,7 +6583,6 @@
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
@@ -6740,6 +6752,33 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-random-string": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz",
|
||||
"integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"type-fest": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-random-string/node_modules/type-fest": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz",
|
||||
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
@@ -8430,9 +8469,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "9.6.0",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz",
|
||||
"integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==",
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
|
||||
"integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sindresorhus/merge-streams": "^4.0.0",
|
||||
@@ -8550,36 +8589,15 @@
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/cookie": {
|
||||
"version": "0.7.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/express/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"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
@@ -8839,7 +8857,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"
|
||||
}
|
||||
@@ -8848,18 +8865,7 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
@@ -11735,6 +11741,21 @@
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/macos-version": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/macos-version/-/macos-version-6.0.0.tgz",
|
||||
"integrity": "sha512-O2S8voA+pMfCHhBn/TIYDXzJ1qNHpPDU32oFxglKnVdJABiYYITt45oLkV9yhwA3E2FDwn3tQqUFrTsr1p3sBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -11870,6 +11891,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -13420,6 +13447,18 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/powershell-utils": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz",
|
||||
"integrity": "sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -14302,6 +14341,107 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/run-jxa": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/run-jxa/-/run-jxa-3.0.0.tgz",
|
||||
"integrity": "sha512-4f2CrY7H+sXkKXJn/cE6qRA3z+NMVO7zvlZ/nUV0e62yWftpiLAfw5eV9ZdomzWd2TXWwEIiGjAT57+lWIzzvA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"execa": "^5.1.1",
|
||||
"macos-version": "^6.0.0",
|
||||
"subsume": "^4.0.0",
|
||||
"type-fest": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/run-jxa/node_modules/execa": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.3",
|
||||
"get-stream": "^6.0.0",
|
||||
"human-signals": "^2.1.0",
|
||||
"is-stream": "^2.0.0",
|
||||
"merge-stream": "^2.0.0",
|
||||
"npm-run-path": "^4.0.1",
|
||||
"onetime": "^5.1.2",
|
||||
"signal-exit": "^3.0.3",
|
||||
"strip-final-newline": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/run-jxa/node_modules/get-stream": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
|
||||
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/run-jxa/node_modules/human-signals": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=10.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/run-jxa/node_modules/npm-run-path": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
||||
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/run-jxa/node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/run-jxa/node_modules/strip-final-newline": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
|
||||
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/run-jxa/node_modules/type-fest": {
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
|
||||
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=12.20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -15238,6 +15378,34 @@
|
||||
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/subsume": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/subsume/-/subsume-4.0.0.tgz",
|
||||
"integrity": "sha512-BWnYJElmHbYZ/zKevy+TG+SsyoFCmRPDHJbR1MzLxkPOv1Jp/4hGhVUtP98s+wZBsBsHwCXvPTP0x287/WMjGg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"unique-string": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/subsume/node_modules/escape-string-regexp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent": {
|
||||
"version": "10.2.3",
|
||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz",
|
||||
@@ -16220,6 +16388,21 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/unique-string": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz",
|
||||
"integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crypto-random-string": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/universal-user-agent": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz",
|
||||
@@ -16286,16 +16469,6 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
@@ -17130,7 +17303,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.33.0-nightly.20260228.1ca5c05d0",
|
||||
"version": "0.33.0-preview.2",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.8",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -17188,7 +17361,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.33.0-nightly.20260228.1ca5c05d0",
|
||||
"version": "0.33.0-preview.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
@@ -17200,7 +17373,7 @@
|
||||
"ansi-regex": "^6.2.2",
|
||||
"chalk": "^4.1.2",
|
||||
"cli-spinners": "^2.9.2",
|
||||
"clipboardy": "^5.0.0",
|
||||
"clipboardy": "~5.2.0",
|
||||
"color-convert": "^2.0.1",
|
||||
"command-exists": "^1.2.9",
|
||||
"comment-json": "^4.2.5",
|
||||
@@ -17271,7 +17444,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.33.0-nightly.20260228.1ca5c05d0",
|
||||
"version": "0.33.0-preview.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.8",
|
||||
@@ -17536,7 +17709,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.33.0-nightly.20260228.1ca5c05d0",
|
||||
"version": "0.33.0-preview.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.16.0"
|
||||
@@ -17551,7 +17724,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.33.0-nightly.20260228.1ca5c05d0",
|
||||
"version": "0.33.0-preview.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -17568,7 +17741,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.33.0-nightly.20260228.1ca5c05d0",
|
||||
"version": "0.33.0-preview.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -17585,7 +17758,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.33.0-nightly.20260228.1ca5c05d0",
|
||||
"version": "0.33.0-preview.2",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.33.0-nightly.20260228.1ca5c05d0",
|
||||
"version": "0.33.0-preview.2",
|
||||
"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.33.0-nightly.20260228.1ca5c05d0"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-preview.2"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.33.0-nightly.20260228.1ca5c05d0",
|
||||
"version": "0.33.0-preview.2",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.33.0-nightly.20260228.1ca5c05d0",
|
||||
"version": "0.33.0-preview.2",
|
||||
"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.33.0-nightly.20260228.1ca5c05d0"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-preview.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
@@ -38,7 +38,7 @@
|
||||
"ansi-regex": "^6.2.2",
|
||||
"chalk": "^4.1.2",
|
||||
"cli-spinners": "^2.9.2",
|
||||
"clipboardy": "^5.0.0",
|
||||
"clipboardy": "~5.2.0",
|
||||
"color-convert": "^2.0.1",
|
||||
"command-exists": "^1.2.9",
|
||||
"comment-json": "^4.2.5",
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
debugLogger,
|
||||
ApprovalMode,
|
||||
type MCPServerConfig,
|
||||
type GeminiCLIExtension,
|
||||
Storage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
|
||||
import {
|
||||
@@ -3524,4 +3526,101 @@ describe('loadCliConfig mcpEnabled', () => {
|
||||
expect(config.getAllowedMcpServers()).toEqual(['serverA']);
|
||||
expect(config.getBlockedMcpServers()).toEqual(['serverB']);
|
||||
});
|
||||
|
||||
describe('extension plan settings', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Storage.prototype, 'getProjectTempDir').mockReturnValue(
|
||||
'/mock/home/user/.gemini/tmp/test-project',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use plan directory from active extension when user has not specified one', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { plan: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'ext-plan',
|
||||
isActive: true,
|
||||
plan: { directory: 'ext-plans-dir' },
|
||||
} as unknown as GeminiCLIExtension,
|
||||
]);
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.storage.getPlansDir()).toContain('ext-plans-dir');
|
||||
});
|
||||
|
||||
it('should NOT use plan directory from active extension when user has specified one', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { plan: true },
|
||||
general: {
|
||||
plan: { directory: 'user-plans-dir' },
|
||||
},
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'ext-plan',
|
||||
isActive: true,
|
||||
plan: { directory: 'ext-plans-dir' },
|
||||
} as unknown as GeminiCLIExtension,
|
||||
]);
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.storage.getPlansDir()).toContain('user-plans-dir');
|
||||
expect(config.storage.getPlansDir()).not.toContain('ext-plans-dir');
|
||||
});
|
||||
|
||||
it('should NOT use plan directory from inactive extension', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { plan: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'ext-plan',
|
||||
isActive: false,
|
||||
plan: { directory: 'ext-plans-dir-inactive' },
|
||||
} as unknown as GeminiCLIExtension,
|
||||
]);
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.storage.getPlansDir()).not.toContain(
|
||||
'ext-plans-dir-inactive',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default path if neither user nor extension settings provide a plan directory', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { plan: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
|
||||
// No extensions providing plan directory
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
// Should return the default managed temp directory path
|
||||
expect(config.storage.getPlansDir()).toBe(
|
||||
path.join(
|
||||
'/mock',
|
||||
'home',
|
||||
'user',
|
||||
'.gemini',
|
||||
'tmp',
|
||||
'test-project',
|
||||
'test-session',
|
||||
'plans',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -511,6 +511,10 @@ export async function loadCliConfig(
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
const extensionPlanSettings = extensionManager
|
||||
.getExtensions()
|
||||
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
|
||||
|
||||
const experimentalJitContext = settings.experimental?.jitContext ?? false;
|
||||
|
||||
let memoryContent: string | HierarchicalMemory = '';
|
||||
@@ -827,7 +831,9 @@ export async function loadCliConfig(
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.experimental?.plan,
|
||||
directWebFetch: settings.experimental?.directWebFetch,
|
||||
planSettings: settings.general?.plan,
|
||||
planSettings: settings.general?.plan?.directory
|
||||
? settings.general.plan
|
||||
: (extensionPlanSettings ?? settings.general?.plan),
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
|
||||
@@ -886,6 +886,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
themes: config.themes,
|
||||
rules,
|
||||
checkers,
|
||||
plan: config.plan,
|
||||
};
|
||||
} catch (e) {
|
||||
debugLogger.error(
|
||||
|
||||
@@ -33,6 +33,15 @@ export interface ExtensionConfig {
|
||||
* These themes will be registered when the extension is activated.
|
||||
*/
|
||||
themes?: CustomTheme[];
|
||||
/**
|
||||
* Planning features configuration contributed by this extension.
|
||||
*/
|
||||
plan?: {
|
||||
/**
|
||||
* The directory where planning artifacts are stored.
|
||||
*/
|
||||
directory?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExtensionUpdateInfo {
|
||||
|
||||
@@ -117,6 +117,10 @@ export interface SettingDefinition {
|
||||
* For map-like objects without explicit `properties`, describes the shape of the values.
|
||||
*/
|
||||
additionalProperties?: SettingCollectionDefinition;
|
||||
/**
|
||||
* Optional unit to display after the value (e.g. '%').
|
||||
*/
|
||||
unit?: string;
|
||||
/**
|
||||
* Optional reference identifier for generators that emit a `$ref`.
|
||||
*/
|
||||
@@ -595,7 +599,7 @@ const SETTINGS_SCHEMA = {
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Hides the context window remaining percentage.',
|
||||
description: 'Hides the context window usage percentage.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
@@ -913,13 +917,14 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
compressionThreshold: {
|
||||
type: 'number',
|
||||
label: 'Compression Threshold',
|
||||
label: 'Context Compression Threshold',
|
||||
category: 'Model',
|
||||
requiresRestart: true,
|
||||
default: 0.5 as number,
|
||||
description:
|
||||
'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).',
|
||||
showInDialog: true,
|
||||
unit: '%',
|
||||
},
|
||||
disableLoopDetection: {
|
||||
type: 'boolean',
|
||||
|
||||
@@ -2544,136 +2544,6 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Expansion Persistence', () => {
|
||||
let rerender: () => void;
|
||||
let unmount: () => void;
|
||||
let stdin: ReturnType<typeof render>['stdin'];
|
||||
|
||||
const setupExpansionPersistenceTest = async (
|
||||
HighPriorityChild?: React.FC,
|
||||
) => {
|
||||
const getTree = () => (
|
||||
<SettingsContext.Provider value={mockSettings}>
|
||||
<KeypressProvider config={mockConfig}>
|
||||
<OverflowProvider>
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>
|
||||
{HighPriorityChild && <HighPriorityChild />}
|
||||
</OverflowProvider>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
|
||||
const renderResult = render(getTree());
|
||||
stdin = renderResult.stdin;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
rerender = () => renderResult.rerender(getTree());
|
||||
unmount = () => renderResult.unmount();
|
||||
};
|
||||
|
||||
const writeStdin = async (sequence: string) => {
|
||||
await act(async () => {
|
||||
stdin.write(sequence);
|
||||
// Advance timers to allow escape sequence parsing and broadcasting
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
rerender();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should reset expansion when a key is NOT handled by anyone', async () => {
|
||||
await setupExpansionPersistenceTest();
|
||||
|
||||
// Expand first
|
||||
act(() => capturedUIActions.setConstrainHeight(false));
|
||||
rerender();
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
|
||||
// Press a random key that no one handles (hits Low priority fallback)
|
||||
await writeStdin('x');
|
||||
|
||||
// Should be reset to true (collapsed)
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should toggle expansion when Ctrl+O is pressed', async () => {
|
||||
await setupExpansionPersistenceTest();
|
||||
|
||||
// Initial state is collapsed
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
|
||||
// Press Ctrl+O to expand (Ctrl+O is sequence \x0f)
|
||||
await writeStdin('\x0f');
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
|
||||
// Press Ctrl+O again to collapse
|
||||
await writeStdin('\x0f');
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT collapse when a high-priority component handles the key (e.g., up/down arrows)', async () => {
|
||||
const NavigationHandler = () => {
|
||||
// use real useKeypress
|
||||
useKeypress(
|
||||
(key: Key) => {
|
||||
if (key.name === 'up' || key.name === 'down') {
|
||||
return true; // Handle navigation
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true, priority: true }, // High priority
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
await setupExpansionPersistenceTest(NavigationHandler);
|
||||
|
||||
// Expand first
|
||||
act(() => capturedUIActions.setConstrainHeight(false));
|
||||
rerender();
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
|
||||
// 1. Simulate Up arrow (handled by high priority child)
|
||||
// CSI A is Up arrow
|
||||
await writeStdin('\u001b[A');
|
||||
|
||||
// Should STILL be expanded
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
|
||||
// 2. Simulate Down arrow (handled by high priority child)
|
||||
// CSI B is Down arrow
|
||||
await writeStdin('\u001b[B');
|
||||
|
||||
// Should STILL be expanded
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
|
||||
// 3. Sanity check: press an unhandled key
|
||||
await writeStdin('x');
|
||||
|
||||
// Should finally collapse
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shortcuts Help Visibility', () => {
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let mockedUseKeypress: Mock;
|
||||
|
||||
@@ -1873,10 +1873,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleGlobalKeypress, {
|
||||
isActive: true,
|
||||
priority: KeypressPriority.Low,
|
||||
});
|
||||
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
|
||||
|
||||
useKeypress(
|
||||
() => {
|
||||
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
coreEvents,
|
||||
processSingleFileContent,
|
||||
type ProcessedFileReadResult,
|
||||
readFileWithEncoding,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { copyToClipboard } from '../utils/commandUtils.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -25,6 +27,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
processSingleFileContent: vi.fn(),
|
||||
readFileWithEncoding: vi.fn(),
|
||||
partToString: vi.fn((val) => val),
|
||||
};
|
||||
});
|
||||
@@ -35,9 +38,14 @@ vi.mock('node:path', async (importOriginal) => {
|
||||
...actual,
|
||||
default: { ...actual },
|
||||
join: vi.fn((...args) => args.join('/')),
|
||||
basename: vi.fn((p) => p.split('/').pop()),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../utils/commandUtils.js', () => ({
|
||||
copyToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('planCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
@@ -115,4 +123,46 @@ describe('planCommand', () => {
|
||||
text: '# Approved Plan Content',
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy subcommand', () => {
|
||||
it('should copy the approved plan to clipboard', async () => {
|
||||
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
|
||||
vi.mocked(
|
||||
mockContext.services.config!.getApprovedPlanPath,
|
||||
).mockReturnValue(mockPlanPath);
|
||||
vi.mocked(readFileWithEncoding).mockResolvedValue('# Plan Content');
|
||||
|
||||
const copySubCommand = planCommand.subCommands?.find(
|
||||
(sc) => sc.name === 'copy',
|
||||
);
|
||||
if (!copySubCommand?.action) throw new Error('Copy action missing');
|
||||
|
||||
await copySubCommand.action(mockContext, '');
|
||||
|
||||
expect(readFileWithEncoding).toHaveBeenCalledWith(mockPlanPath);
|
||||
expect(copyToClipboard).toHaveBeenCalledWith('# Plan Content');
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'Plan copied to clipboard (approved-plan.md).',
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn if no approved plan is found', async () => {
|
||||
vi.mocked(
|
||||
mockContext.services.config!.getApprovedPlanPath,
|
||||
).mockReturnValue(undefined);
|
||||
|
||||
const copySubCommand = planCommand.subCommands?.find(
|
||||
(sc) => sc.name === 'copy',
|
||||
);
|
||||
if (!copySubCommand?.action) throw new Error('Copy action missing');
|
||||
|
||||
await copySubCommand.action(mockContext, '');
|
||||
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
'No approved plan found to copy.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,22 +4,54 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import {
|
||||
type CommandContext,
|
||||
CommandKind,
|
||||
type SlashCommand,
|
||||
} from './types.js';
|
||||
import {
|
||||
ApprovalMode,
|
||||
coreEvents,
|
||||
debugLogger,
|
||||
processSingleFileContent,
|
||||
partToString,
|
||||
readFileWithEncoding,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { MessageType } from '../types.js';
|
||||
import * as path from 'node:path';
|
||||
import { copyToClipboard } from '../utils/commandUtils.js';
|
||||
|
||||
async function copyAction(context: CommandContext) {
|
||||
const config = context.services.config;
|
||||
if (!config) {
|
||||
debugLogger.debug('Plan copy command: config is not available in context');
|
||||
return;
|
||||
}
|
||||
|
||||
const planPath = config.getApprovedPlanPath();
|
||||
|
||||
if (!planPath) {
|
||||
coreEvents.emitFeedback('warning', 'No approved plan found to copy.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFileWithEncoding(planPath);
|
||||
await copyToClipboard(content);
|
||||
coreEvents.emitFeedback(
|
||||
'info',
|
||||
`Plan copied to clipboard (${path.basename(planPath)}).`,
|
||||
);
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback('error', `Failed to copy plan: ${error}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export const planCommand: SlashCommand = {
|
||||
name: 'plan',
|
||||
description: 'Switch to Plan Mode and view current plan',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
autoExecute: false,
|
||||
action: async (context) => {
|
||||
const config = context.services.config;
|
||||
if (!config) {
|
||||
@@ -62,4 +94,13 @@ export const planCommand: SlashCommand = {
|
||||
);
|
||||
}
|
||||
},
|
||||
subCommands: [
|
||||
{
|
||||
name: 'copy',
|
||||
description: 'Copy the currently approved plan to your clipboard',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: copyAction,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
@@ -17,18 +17,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/settings.js', () => ({
|
||||
DEFAULT_MODEL_CONFIGS: {},
|
||||
LoadedSettings: class {
|
||||
constructor() {
|
||||
// this.merged = {};
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ContextUsageDisplay', () => {
|
||||
it('renders correct percentage left', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
it('renders correct percentage used', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={5000}
|
||||
model="gemini-pro"
|
||||
@@ -37,27 +28,56 @@ describe('ContextUsageDisplay', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('50% context left');
|
||||
expect(output).toContain('50% context used');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders short label when terminal width is small', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
it('renders correctly when usage is 0%', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={0}
|
||||
model="gemini-pro"
|
||||
terminalWidth={120}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('0% context used');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders abbreviated label when terminal width is small', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={2000}
|
||||
model="gemini-pro"
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
{ width: 80 },
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('80%');
|
||||
expect(output).not.toContain('context left');
|
||||
expect(output).toContain('20%');
|
||||
expect(output).not.toContain('context used');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders 0% when full', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
it('renders 80% correctly', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={8000}
|
||||
model="gemini-pro"
|
||||
terminalWidth={120}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('80% context used');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders 100% when full', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={10000}
|
||||
model="gemini-pro"
|
||||
@@ -66,7 +86,7 @@ describe('ContextUsageDisplay', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('0% context left');
|
||||
expect(output).toContain('100% context used');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { getContextUsagePercentage } from '../utils/contextUsage.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import {
|
||||
MIN_TERMINAL_WIDTH_FOR_FULL_LABEL,
|
||||
DEFAULT_COMPRESSION_THRESHOLD,
|
||||
} from '../constants.js';
|
||||
|
||||
export const ContextUsageDisplay = ({
|
||||
promptTokenCount,
|
||||
@@ -14,17 +19,30 @@ export const ContextUsageDisplay = ({
|
||||
terminalWidth,
|
||||
}: {
|
||||
promptTokenCount: number;
|
||||
model: string;
|
||||
model: string | undefined;
|
||||
terminalWidth: number;
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const percentage = getContextUsagePercentage(promptTokenCount, model);
|
||||
const percentageLeft = ((1 - percentage) * 100).toFixed(0);
|
||||
const percentageUsed = (percentage * 100).toFixed(0);
|
||||
|
||||
const label = terminalWidth < 100 ? '%' : '% context left';
|
||||
const threshold =
|
||||
settings.merged.model?.compressionThreshold ??
|
||||
DEFAULT_COMPRESSION_THRESHOLD;
|
||||
|
||||
let textColor = theme.text.secondary;
|
||||
if (percentage >= 1.0) {
|
||||
textColor = theme.status.error;
|
||||
} else if (percentage >= threshold) {
|
||||
textColor = theme.status.warning;
|
||||
}
|
||||
|
||||
const label =
|
||||
terminalWidth < MIN_TERMINAL_WIDTH_FOR_FULL_LABEL ? '%' : '% context used';
|
||||
|
||||
return (
|
||||
<Text color={theme.text.secondary}>
|
||||
{percentageLeft}
|
||||
<Text color={textColor}>
|
||||
{percentageUsed}
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('DetailedMessagesDisplay', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides the F12 hint in low error verbosity mode', async () => {
|
||||
it('shows the F12 hint even in low error verbosity mode', async () => {
|
||||
const messages: ConsoleMessageItem[] = [
|
||||
{ type: 'error', content: 'Error message', count: 1 },
|
||||
];
|
||||
@@ -95,7 +95,7 @@ describe('DetailedMessagesDisplay', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('(F12 to close)');
|
||||
expect(lastFrame()).toContain('(F12 to close)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
ScrollableList,
|
||||
type ScrollableListRef,
|
||||
} from './shared/ScrollableList.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
interface DetailedMessagesDisplayProps {
|
||||
messages: ConsoleMessageItem[];
|
||||
@@ -29,10 +27,6 @@ export const DetailedMessagesDisplay: React.FC<
|
||||
DetailedMessagesDisplayProps
|
||||
> = ({ messages, maxHeight, width, hasFocus }) => {
|
||||
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
const showHotkeyHint =
|
||||
settings.merged.ui.errorVerbosity === 'full' || config.getDebugMode();
|
||||
|
||||
const borderAndPadding = 3;
|
||||
|
||||
@@ -71,10 +65,7 @@ export const DetailedMessagesDisplay: React.FC<
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Debug Console{' '}
|
||||
{showHotkeyHint && (
|
||||
<Text color={theme.text.secondary}>(F12 to close)</Text>
|
||||
)}
|
||||
Debug Console <Text color={theme.text.secondary}>(F12 to close)</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box height={maxHeight} width={width - borderAndPadding}>
|
||||
|
||||
@@ -15,6 +15,19 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
||||
|
||||
let mockIsDevelopment = false;
|
||||
|
||||
vi.mock('../../utils/installationInfo.js', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('../../utils/installationInfo.js')>();
|
||||
return {
|
||||
...original,
|
||||
get isDevelopment() {
|
||||
return mockIsDevelopment;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
@@ -161,7 +174,7 @@ describe('<Footer />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\d+% context left/);
|
||||
expect(lastFrame()).toMatch(/\d+% context used/);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -216,7 +229,7 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('Usage remaining');
|
||||
expect(lastFrame()).not.toContain('used');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
@@ -249,7 +262,7 @@ describe('<Footer />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('displays the model name and abbreviated context percentage', async () => {
|
||||
it('displays the model name and abbreviated context used label on narrow terminals', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
@@ -267,6 +280,7 @@ describe('<Footer />', () => {
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\d+%/);
|
||||
expect(lastFrame()).not.toContain('context used');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -464,7 +478,7 @@ describe('<Footer />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).not.toMatch(/\d+% context left/);
|
||||
expect(lastFrame()).not.toMatch(/\d+% context used/);
|
||||
unmount();
|
||||
});
|
||||
it('shows the context percentage when hideContextPercentage is false', async () => {
|
||||
@@ -484,7 +498,7 @@ describe('<Footer />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\d+% context left/);
|
||||
expect(lastFrame()).toMatch(/\d+% context used/);
|
||||
unmount();
|
||||
});
|
||||
it('renders complete footer in narrow terminal (baseline narrow)', async () => {
|
||||
@@ -509,7 +523,15 @@ describe('<Footer />', () => {
|
||||
});
|
||||
|
||||
describe('error summary visibility', () => {
|
||||
it('hides error summary in low verbosity mode', async () => {
|
||||
beforeEach(() => {
|
||||
mockIsDevelopment = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockIsDevelopment = false;
|
||||
});
|
||||
|
||||
it('hides error summary in low verbosity mode out of dev mode', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
@@ -530,6 +552,28 @@ describe('<Footer />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows error summary in low verbosity mode in dev mode', async () => {
|
||||
mockIsDevelopment = true;
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
errorCount: 2,
|
||||
showErrorDetails: false,
|
||||
},
|
||||
settings: createMockSettings({
|
||||
merged: { ui: { errorVerbosity: 'low' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('F12 for details');
|
||||
expect(lastFrame()).toContain('2 errors');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows error summary in full verbosity mode', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
|
||||
@@ -62,7 +62,9 @@ export const Footer: React.FC = () => {
|
||||
config.getDebugMode() || settings.merged.ui.showMemoryUsage;
|
||||
const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
|
||||
const showErrorSummary =
|
||||
!showErrorDetails && errorCount > 0 && (isFullErrorVerbosity || debugMode);
|
||||
!showErrorDetails &&
|
||||
errorCount > 0 &&
|
||||
(isFullErrorVerbosity || debugMode || isDevelopment);
|
||||
const hideCWD = settings.merged.ui.footer.hideCWD;
|
||||
const hideSandboxStatus = settings.merged.ui.footer.hideSandboxStatus;
|
||||
const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
|
||||
|
||||
@@ -99,6 +99,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
{itemForDisplay.type === 'info' && (
|
||||
<InfoMessage
|
||||
text={itemForDisplay.text}
|
||||
secondaryText={itemForDisplay.secondaryText}
|
||||
icon={itemForDisplay.icon}
|
||||
color={itemForDisplay.color}
|
||||
marginBottom={itemForDisplay.marginBottom}
|
||||
|
||||
@@ -89,11 +89,12 @@ const renderStatusDisplay = async (
|
||||
};
|
||||
|
||||
describe('StatusDisplay', () => {
|
||||
const originalEnv = process.env;
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('GEMINI_SYSTEM_MD', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env['GEMINI_SYSTEM_MD'];
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -112,7 +113,7 @@ describe('StatusDisplay', () => {
|
||||
});
|
||||
|
||||
it('renders system md indicator if env var is set', async () => {
|
||||
process.env['GEMINI_SYSTEM_MD'] = 'true';
|
||||
vi.stubEnv('GEMINI_SYSTEM_MD', 'true');
|
||||
const { lastFrame, unmount } = await renderStatusDisplay();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('<UserIdentity />', () => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('test@example.com');
|
||||
expect(output).toContain('/auth');
|
||||
expect(output).not.toContain('/upgrade');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -74,6 +75,7 @@ describe('<UserIdentity />', () => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Logged in with Google');
|
||||
expect(output).toContain('/auth');
|
||||
expect(output).not.toContain('/upgrade');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -130,6 +132,26 @@ describe('<UserIdentity />', () => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(`Authenticated with ${AuthType.USE_GEMINI}`);
|
||||
expect(output).toContain('/auth');
|
||||
expect(output).not.toContain('/upgrade');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render specific tier name when provided', async () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
model: 'gemini-pro',
|
||||
} as unknown as ContentGeneratorConfig);
|
||||
vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue('Enterprise Tier');
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<UserIdentity config={mockConfig} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Enterprise Tier');
|
||||
expect(output).toContain('/upgrade');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,12 +53,14 @@ export const UserIdentity: React.FC<UserIdentityProps> = ({ config }) => {
|
||||
</Box>
|
||||
|
||||
{/* Tier Name /upgrade */}
|
||||
<Box>
|
||||
<Text color={theme.text.primary} wrap="truncate-end">
|
||||
{tierName ?? 'Gemini Code Assist for individuals'}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> /upgrade</Text>
|
||||
</Box>
|
||||
{tierName && (
|
||||
<Box>
|
||||
<Text color={theme.text.primary} wrap="truncate-end">
|
||||
{tierName}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> /upgrade</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,12 +11,12 @@ exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
|
||||
`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `
|
||||
" ...s/to/make/it/long no sandbox /model gemini-pro 100%
|
||||
" ...s/to/make/it/long no sandbox /model gemini-pro 0%
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `
|
||||
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 100% context left
|
||||
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 0% context used
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
|
||||
|
||||
interface InfoMessageProps {
|
||||
text: string;
|
||||
secondaryText?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
marginBottom?: number;
|
||||
@@ -18,6 +19,7 @@ interface InfoMessageProps {
|
||||
|
||||
export const InfoMessage: React.FC<InfoMessageProps> = ({
|
||||
text,
|
||||
secondaryText,
|
||||
icon,
|
||||
color,
|
||||
marginBottom,
|
||||
@@ -35,6 +37,9 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({
|
||||
{text.split('\n').map((line, index) => (
|
||||
<Text wrap="wrap" key={index}>
|
||||
<RenderInline text={line} defaultColor={color} />
|
||||
{index === text.split('\n').length - 1 && secondaryText && (
|
||||
<Text color={theme.text.secondary}> {secondaryText}</Text>
|
||||
)}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -48,3 +48,9 @@ export const ACTIVE_SHELL_MAX_LINES = 15;
|
||||
|
||||
// Max lines to preserve in history for completed shell commands
|
||||
export const COMPLETED_SHELL_MAX_LINES = 15;
|
||||
|
||||
/** Minimum terminal width required to show the full context used label */
|
||||
export const MIN_TERMINAL_WIDTH_FOR_FULL_LABEL = 100;
|
||||
|
||||
/** Default context usage fraction at which to trigger compression */
|
||||
export const DEFAULT_COMPRESSION_THRESHOLD = 0.5;
|
||||
|
||||
@@ -30,7 +30,7 @@ export const INFORMATIVE_TIPS = [
|
||||
'Choose a specific Gemini model for conversations (/settings)…',
|
||||
'Limit the number of turns in your session history (/settings)…',
|
||||
'Automatically summarize large tool outputs to save tokens (settings.json)…',
|
||||
'Control when chat history gets compressed based on token usage (settings.json)…',
|
||||
'Control when chat history gets compressed based on context compression threshold (settings.json)…',
|
||||
'Define custom context file names, like CONTEXT.md (settings.json)…',
|
||||
'Set max directories to scan for context files (/settings)…',
|
||||
'Expand your workspace with additional directories (/directory)…',
|
||||
|
||||
@@ -50,6 +50,7 @@ import { MessageType, StreamingState } from '../types.js';
|
||||
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
// --- MOCKS ---
|
||||
const mockSendMessageStream = vi
|
||||
@@ -2300,14 +2301,14 @@ describe('useGeminiStream', () => {
|
||||
requestTokens: 20,
|
||||
remainingTokens: 80,
|
||||
expectedMessage:
|
||||
'Sending this message (20 tokens) might exceed the remaining context window limit (80 tokens).',
|
||||
'Sending this message (20 tokens) might exceed the context window limit (80 tokens left).',
|
||||
},
|
||||
{
|
||||
name: 'with suggestion when remaining tokens are < 75% of limit',
|
||||
requestTokens: 30,
|
||||
remainingTokens: 70,
|
||||
expectedMessage:
|
||||
'Sending this message (30 tokens) might exceed the remaining context window limit (70 tokens). Please try reducing the size of your message or use the `/compress` command to compress the chat history.',
|
||||
'Sending this message (30 tokens) might exceed the context window limit (70 tokens left). Please try reducing the size of your message or use the `/compress` command to compress the chat history.',
|
||||
},
|
||||
])(
|
||||
'should add message $name',
|
||||
@@ -2388,6 +2389,43 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should add informational messages when ChatCompressed event is received', async () => {
|
||||
vi.mocked(tokenLimit).mockReturnValue(10000);
|
||||
// Setup mock to return a stream with ChatCompressed event
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.ChatCompressed,
|
||||
value: {
|
||||
originalTokenCount: 1000,
|
||||
newTokenCount: 500,
|
||||
compressionStatus: 'compressed',
|
||||
},
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
const { result } = renderHookWithDefaults();
|
||||
|
||||
// Submit a query
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('Test compression');
|
||||
});
|
||||
|
||||
// Check that the succinct info message was added
|
||||
await waitFor(() => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Context compressed from 10% to 5%.',
|
||||
secondaryText: 'Change threshold in /settings.',
|
||||
color: theme.status.warning,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
reason: 'STOP',
|
||||
|
||||
@@ -108,9 +108,9 @@ enum StreamProcessingStatus {
|
||||
}
|
||||
|
||||
const SUPPRESSED_TOOL_ERRORS_NOTE =
|
||||
'Some internal tool attempts failed before this final error. Press F12 for diagnostics, or set ui.errorVerbosity to full for full details.';
|
||||
'Some internal tool attempts failed before this final error. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for details.';
|
||||
const LOW_VERBOSITY_FAILURE_NOTE =
|
||||
'This request failed. Press F12 for diagnostics, or set ui.errorVerbosity to full for full details.';
|
||||
'This request failed. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for full details.';
|
||||
|
||||
function isShellToolData(data: unknown): data is ShellToolData {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
@@ -1065,16 +1065,27 @@ export const useGeminiStream = (
|
||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||
setPendingHistoryItem(null);
|
||||
}
|
||||
return addItem({
|
||||
type: 'info',
|
||||
text:
|
||||
`IMPORTANT: This conversation exceeded the compress threshold. ` +
|
||||
`A compressed context will be sent for future messages (compressed from: ` +
|
||||
`${eventValue?.originalTokenCount ?? 'unknown'} to ` +
|
||||
`${eventValue?.newTokenCount ?? 'unknown'} tokens).`,
|
||||
});
|
||||
|
||||
const limit = tokenLimit(config.getModel());
|
||||
const originalPercentage = Math.round(
|
||||
((eventValue?.originalTokenCount ?? 0) / limit) * 100,
|
||||
);
|
||||
const newPercentage = Math.round(
|
||||
((eventValue?.newTokenCount ?? 0) / limit) * 100,
|
||||
);
|
||||
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Context compressed from ${originalPercentage}% to ${newPercentage}%.`,
|
||||
secondaryText: `Change threshold in /settings.`,
|
||||
color: theme.status.warning,
|
||||
marginBottom: 1,
|
||||
} as HistoryItemInfo,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
},
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config],
|
||||
);
|
||||
|
||||
const handleMaxSessionTurnsEvent = useCallback(
|
||||
@@ -1094,12 +1105,12 @@ export const useGeminiStream = (
|
||||
|
||||
const limit = tokenLimit(config.getModel());
|
||||
|
||||
const isLessThan75Percent =
|
||||
const isMoreThan25PercentUsed =
|
||||
limit > 0 && remainingTokenCount < limit * 0.75;
|
||||
|
||||
let text = `Sending this message (${estimatedRequestTokenCount} tokens) might exceed the remaining context window limit (${remainingTokenCount} tokens).`;
|
||||
let text = `Sending this message (${estimatedRequestTokenCount} tokens) might exceed the context window limit (${remainingTokenCount.toLocaleString()} tokens left).`;
|
||||
|
||||
if (isLessThan75Percent) {
|
||||
if (isMoreThan25PercentUsed) {
|
||||
text +=
|
||||
' Please try reducing the size of your message or use the `/compress` command to compress the chat history.';
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('useLoadingIndicator', () => {
|
||||
shouldShowFocusHint?: boolean;
|
||||
retryStatus?: RetryAttemptPayload | null;
|
||||
mode?: LoadingPhrasesMode;
|
||||
errorVerbosity?: 'low' | 'full';
|
||||
errorVerbosity: 'low' | 'full';
|
||||
}) {
|
||||
hookResult = useLoadingIndicator({
|
||||
streamingState,
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface UseLoadingIndicatorProps {
|
||||
retryStatus: RetryAttemptPayload | null;
|
||||
loadingPhrasesMode?: LoadingPhrasesMode;
|
||||
customWittyPhrases?: string[];
|
||||
errorVerbosity?: 'low' | 'full';
|
||||
errorVerbosity: 'low' | 'full';
|
||||
}
|
||||
|
||||
export const useLoadingIndicator = ({
|
||||
@@ -31,7 +31,7 @@ export const useLoadingIndicator = ({
|
||||
retryStatus,
|
||||
loadingPhrasesMode,
|
||||
customWittyPhrases,
|
||||
errorVerbosity = 'full',
|
||||
errorVerbosity,
|
||||
}: UseLoadingIndicatorProps) => {
|
||||
const [timerResetKey, setTimerResetKey] = useState(0);
|
||||
const isTimerActive = streamingState === StreamingState.Responding;
|
||||
|
||||
@@ -151,6 +151,7 @@ export type HistoryItemGeminiContent = HistoryItemBase & {
|
||||
export type HistoryItemInfo = HistoryItemBase & {
|
||||
type: 'info';
|
||||
text: string;
|
||||
secondaryText?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
marginBottom?: number;
|
||||
|
||||
@@ -163,7 +163,6 @@ describe('commandUtils', () => {
|
||||
it('should return true when query starts with @', () => {
|
||||
expect(isAtCommand('@file')).toBe(true);
|
||||
expect(isAtCommand('@path/to/file')).toBe(true);
|
||||
expect(isAtCommand('@')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when query contains @ preceded by whitespace', () => {
|
||||
@@ -172,17 +171,36 @@ describe('commandUtils', () => {
|
||||
expect(isAtCommand(' @file')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when query does not start with @ and has no spaced @', () => {
|
||||
it('should return true when @ is preceded by non-whitespace (external editor scenario)', () => {
|
||||
// When a user composes a prompt in an external editor, @-references may
|
||||
// appear after punctuation characters such as ':' or '(' without a space.
|
||||
// The processor must still recognise these as @-commands so that the
|
||||
// referenced files are pre-loaded before the query is sent to the model.
|
||||
expect(isAtCommand('check:@file.py')).toBe(true);
|
||||
expect(isAtCommand('analyze(@file.py)')).toBe(true);
|
||||
expect(isAtCommand('hello@file')).toBe(true);
|
||||
expect(isAtCommand('text@path/to/file')).toBe(true);
|
||||
expect(isAtCommand('user@host')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when query does not contain any @<path> pattern', () => {
|
||||
expect(isAtCommand('file')).toBe(false);
|
||||
expect(isAtCommand('hello')).toBe(false);
|
||||
expect(isAtCommand('')).toBe(false);
|
||||
expect(isAtCommand('email@domain.com')).toBe(false);
|
||||
expect(isAtCommand('user@host')).toBe(false);
|
||||
// A bare '@' with no following path characters is not an @-command.
|
||||
expect(isAtCommand('@')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when @ is not preceded by whitespace', () => {
|
||||
expect(isAtCommand('hello@file')).toBe(false);
|
||||
expect(isAtCommand('text@path')).toBe(false);
|
||||
it('should return false when @ is escaped with a backslash', () => {
|
||||
expect(isAtCommand('\\@file')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for multi-line external editor prompts with @-references', () => {
|
||||
expect(isAtCommand('Please review:\n@src/main.py\nand fix bugs.')).toBe(
|
||||
true,
|
||||
);
|
||||
// @file after a colon on the same line.
|
||||
expect(isAtCommand('Files:@src/a.py,@src/b.py')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,18 +10,29 @@ import type { SlashCommand } from '../commands/types.js';
|
||||
import fs from 'node:fs';
|
||||
import type { Writable } from 'node:stream';
|
||||
import type { Settings } from '../../config/settingsSchema.js';
|
||||
import { AT_COMMAND_PATH_REGEX_SOURCE } from '../hooks/atCommandProcessor.js';
|
||||
|
||||
// Pre-compiled regex for detecting @<path> patterns consistent with parseAllAtCommands.
|
||||
// Uses the same AT_COMMAND_PATH_REGEX_SOURCE so that isAtCommand is true whenever
|
||||
// parseAllAtCommands would find at least one atPath part.
|
||||
const AT_COMMAND_DETECT_REGEX = new RegExp(
|
||||
`(?<!\\\\)@${AT_COMMAND_PATH_REGEX_SOURCE}`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Checks if a query string potentially represents an '@' command.
|
||||
* It triggers if the query starts with '@' or contains '@' preceded by whitespace
|
||||
* and followed by a non-whitespace character.
|
||||
* Returns true if the query contains any '@<path>' pattern that would be
|
||||
* recognised by the @ command processor, regardless of what character
|
||||
* precedes the '@' sign. This ensures that prompts written in an external
|
||||
* editor (where '@' may follow punctuation like ':' or '(') are correctly
|
||||
* identified and their referenced files pre-loaded before the query is sent
|
||||
* to the model.
|
||||
*
|
||||
* @param query The input query string.
|
||||
* @returns True if the query looks like an '@' command, false otherwise.
|
||||
*/
|
||||
export const isAtCommand = (query: string): boolean =>
|
||||
// Check if starts with @ OR has a space, then @
|
||||
query.startsWith('@') || /\s@/.test(query);
|
||||
AT_COMMAND_DETECT_REGEX.test(query);
|
||||
|
||||
/**
|
||||
* Checks if a query string potentially represents an '/' command.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
SettingScope,
|
||||
type LoadedSettings,
|
||||
type LoadableSettingScope,
|
||||
} from '../config/settings.js';
|
||||
import { enableAgent, disableAgent } from './agentSettings.js';
|
||||
|
||||
function createMockLoadedSettings(opts: {
|
||||
userSettings?: Record<string, unknown>;
|
||||
workspaceSettings?: Record<string, unknown>;
|
||||
userPath?: string;
|
||||
workspacePath?: string;
|
||||
}): LoadedSettings {
|
||||
const scopes: Record<
|
||||
string,
|
||||
{
|
||||
settings: Record<string, unknown>;
|
||||
originalSettings: Record<string, unknown>;
|
||||
path: string;
|
||||
}
|
||||
> = {
|
||||
[SettingScope.User]: {
|
||||
settings: opts.userSettings ?? {},
|
||||
originalSettings: opts.userSettings ?? {},
|
||||
path: opts.userPath ?? '/home/user/.gemini/settings.json',
|
||||
},
|
||||
[SettingScope.Workspace]: {
|
||||
settings: opts.workspaceSettings ?? {},
|
||||
originalSettings: opts.workspaceSettings ?? {},
|
||||
path: opts.workspacePath ?? '/project/.gemini/settings.json',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
forScope: vi.fn((scope: LoadableSettingScope) => scopes[scope]),
|
||||
setValue: vi.fn(),
|
||||
} as unknown as LoadedSettings;
|
||||
}
|
||||
|
||||
describe('agentSettings', () => {
|
||||
describe('agentStrategy (via enableAgent / disableAgent)', () => {
|
||||
describe('enableAgent', () => {
|
||||
it('should return no-op when the agent is already enabled in both scopes', () => {
|
||||
const settings = createMockLoadedSettings({
|
||||
userSettings: {
|
||||
agents: { overrides: { 'my-agent': { enabled: true } } },
|
||||
},
|
||||
workspaceSettings: {
|
||||
agents: { overrides: { 'my-agent': { enabled: true } } },
|
||||
},
|
||||
});
|
||||
|
||||
const result = enableAgent(settings, 'my-agent');
|
||||
|
||||
expect(result.status).toBe('no-op');
|
||||
expect(result.action).toBe('enable');
|
||||
expect(result.agentName).toBe('my-agent');
|
||||
expect(result.modifiedScopes).toHaveLength(0);
|
||||
expect(settings.setValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should enable the agent when not present in any scope', () => {
|
||||
const settings = createMockLoadedSettings({
|
||||
userSettings: {},
|
||||
workspaceSettings: {},
|
||||
});
|
||||
|
||||
const result = enableAgent(settings, 'my-agent');
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.action).toBe('enable');
|
||||
expect(result.agentName).toBe('my-agent');
|
||||
expect(result.modifiedScopes).toHaveLength(2);
|
||||
expect(settings.setValue).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should enable the agent only in the scope where it is not enabled', () => {
|
||||
const settings = createMockLoadedSettings({
|
||||
userSettings: {
|
||||
agents: { overrides: { 'my-agent': { enabled: true } } },
|
||||
},
|
||||
workspaceSettings: {
|
||||
agents: { overrides: { 'my-agent': { enabled: false } } },
|
||||
},
|
||||
});
|
||||
|
||||
const result = enableAgent(settings, 'my-agent');
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.modifiedScopes).toHaveLength(1);
|
||||
expect(result.modifiedScopes[0].scope).toBe(SettingScope.Workspace);
|
||||
expect(result.alreadyInStateScopes).toHaveLength(1);
|
||||
expect(result.alreadyInStateScopes[0].scope).toBe(SettingScope.User);
|
||||
expect(settings.setValue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableAgent', () => {
|
||||
it('should return no-op when agent is already explicitly disabled', () => {
|
||||
const settings = createMockLoadedSettings({
|
||||
userSettings: {
|
||||
agents: { overrides: { 'my-agent': { enabled: false } } },
|
||||
},
|
||||
});
|
||||
|
||||
const result = disableAgent(settings, 'my-agent', SettingScope.User);
|
||||
|
||||
expect(result.status).toBe('no-op');
|
||||
expect(result.action).toBe('disable');
|
||||
expect(result.agentName).toBe('my-agent');
|
||||
expect(settings.setValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should disable the agent when it is currently enabled', () => {
|
||||
const settings = createMockLoadedSettings({
|
||||
userSettings: {
|
||||
agents: { overrides: { 'my-agent': { enabled: true } } },
|
||||
},
|
||||
});
|
||||
|
||||
const result = disableAgent(settings, 'my-agent', SettingScope.User);
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.action).toBe('disable');
|
||||
expect(result.modifiedScopes).toHaveLength(1);
|
||||
expect(result.modifiedScopes[0].scope).toBe(SettingScope.User);
|
||||
expect(settings.setValue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return error for an invalid scope', () => {
|
||||
const settings = createMockLoadedSettings({});
|
||||
|
||||
const result = disableAgent(settings, 'my-agent', SettingScope.Session);
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.error).toContain('Invalid settings scope');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,30 +4,41 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SettingScope, LoadedSettings } from '../config/settings.js';
|
||||
import {
|
||||
SettingScope,
|
||||
isLoadableSettingScope,
|
||||
type LoadedSettings,
|
||||
} from '../config/settings.js';
|
||||
import type { ModifiedScope } from './skillSettings.js';
|
||||
type FeatureActionResult,
|
||||
type FeatureToggleStrategy,
|
||||
enableFeature,
|
||||
disableFeature,
|
||||
} from './featureToggleUtils.js';
|
||||
|
||||
export type AgentActionStatus = 'success' | 'no-op' | 'error';
|
||||
|
||||
/**
|
||||
* Metadata representing the result of an agent settings operation.
|
||||
*/
|
||||
export interface AgentActionResult {
|
||||
status: AgentActionStatus;
|
||||
export interface AgentActionResult
|
||||
extends Omit<FeatureActionResult, 'featureName'> {
|
||||
agentName: string;
|
||||
action: 'enable' | 'disable';
|
||||
/** Scopes where the agent's state was actually changed. */
|
||||
modifiedScopes: ModifiedScope[];
|
||||
/** Scopes where the agent was already in the desired state. */
|
||||
alreadyInStateScopes: ModifiedScope[];
|
||||
/** Error message if status is 'error'. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const agentStrategy: FeatureToggleStrategy = {
|
||||
needsEnabling: (settings, scope, agentName) => {
|
||||
const agentOverrides = settings.forScope(scope).settings.agents?.overrides;
|
||||
return agentOverrides?.[agentName]?.enabled !== true;
|
||||
},
|
||||
enable: (settings, scope, agentName) => {
|
||||
settings.setValue(scope, `agents.overrides.${agentName}.enabled`, true);
|
||||
},
|
||||
isExplicitlyDisabled: (settings, scope, agentName) => {
|
||||
const agentOverrides = settings.forScope(scope).settings.agents?.overrides;
|
||||
return agentOverrides?.[agentName]?.enabled === false;
|
||||
},
|
||||
disable: (settings, scope, agentName) => {
|
||||
settings.setValue(scope, `agents.overrides.${agentName}.enabled`, false);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Enables an agent by ensuring it is enabled in any writable scope (User and Workspace).
|
||||
* It sets `agents.overrides.<agentName>.enabled` to `true`.
|
||||
@@ -36,50 +47,14 @@ export function enableAgent(
|
||||
settings: LoadedSettings,
|
||||
agentName: string,
|
||||
): AgentActionResult {
|
||||
const writableScopes = [SettingScope.Workspace, SettingScope.User];
|
||||
const foundInDisabledScopes: ModifiedScope[] = [];
|
||||
const alreadyEnabledScopes: ModifiedScope[] = [];
|
||||
|
||||
for (const scope of writableScopes) {
|
||||
if (isLoadableSettingScope(scope)) {
|
||||
const scopePath = settings.forScope(scope).path;
|
||||
const agentOverrides =
|
||||
settings.forScope(scope).settings.agents?.overrides;
|
||||
const isEnabled = agentOverrides?.[agentName]?.enabled === true;
|
||||
|
||||
if (!isEnabled) {
|
||||
foundInDisabledScopes.push({ scope, path: scopePath });
|
||||
} else {
|
||||
alreadyEnabledScopes.push({ scope, path: scopePath });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundInDisabledScopes.length === 0) {
|
||||
return {
|
||||
status: 'no-op',
|
||||
agentName,
|
||||
action: 'enable',
|
||||
modifiedScopes: [],
|
||||
alreadyInStateScopes: alreadyEnabledScopes,
|
||||
};
|
||||
}
|
||||
|
||||
const modifiedScopes: ModifiedScope[] = [];
|
||||
for (const { scope, path } of foundInDisabledScopes) {
|
||||
if (isLoadableSettingScope(scope)) {
|
||||
// Explicitly enable it.
|
||||
settings.setValue(scope, `agents.overrides.${agentName}.enabled`, true);
|
||||
modifiedScopes.push({ scope, path });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
const { featureName, ...rest } = enableFeature(
|
||||
settings,
|
||||
agentName,
|
||||
action: 'enable',
|
||||
modifiedScopes,
|
||||
alreadyInStateScopes: alreadyEnabledScopes,
|
||||
agentStrategy,
|
||||
);
|
||||
return {
|
||||
...rest,
|
||||
agentName: featureName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,56 +66,14 @@ export function disableAgent(
|
||||
agentName: string,
|
||||
scope: SettingScope,
|
||||
): AgentActionResult {
|
||||
if (!isLoadableSettingScope(scope)) {
|
||||
return {
|
||||
status: 'error',
|
||||
agentName,
|
||||
action: 'disable',
|
||||
modifiedScopes: [],
|
||||
alreadyInStateScopes: [],
|
||||
error: `Invalid settings scope: ${scope}`,
|
||||
};
|
||||
}
|
||||
|
||||
const scopePath = settings.forScope(scope).path;
|
||||
const agentOverrides = settings.forScope(scope).settings.agents?.overrides;
|
||||
const isEnabled = agentOverrides?.[agentName]?.enabled !== false;
|
||||
|
||||
if (!isEnabled) {
|
||||
return {
|
||||
status: 'no-op',
|
||||
agentName,
|
||||
action: 'disable',
|
||||
modifiedScopes: [],
|
||||
alreadyInStateScopes: [{ scope, path: scopePath }],
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's already disabled in the other writable scope
|
||||
const otherScope =
|
||||
scope === SettingScope.Workspace
|
||||
? SettingScope.User
|
||||
: SettingScope.Workspace;
|
||||
const alreadyDisabledInOther: ModifiedScope[] = [];
|
||||
|
||||
if (isLoadableSettingScope(otherScope)) {
|
||||
const otherOverrides =
|
||||
settings.forScope(otherScope).settings.agents?.overrides;
|
||||
if (otherOverrides?.[agentName]?.enabled === false) {
|
||||
alreadyDisabledInOther.push({
|
||||
scope: otherScope,
|
||||
path: settings.forScope(otherScope).path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
settings.setValue(scope, `agents.overrides.${agentName}.enabled`, false);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
const { featureName, ...rest } = disableFeature(
|
||||
settings,
|
||||
agentName,
|
||||
action: 'disable',
|
||||
modifiedScopes: [{ scope, path: scopePath }],
|
||||
alreadyInStateScopes: alreadyDisabledInOther,
|
||||
scope,
|
||||
agentStrategy,
|
||||
);
|
||||
return {
|
||||
...rest,
|
||||
agentName: featureName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
enableFeature,
|
||||
disableFeature,
|
||||
type FeatureToggleStrategy,
|
||||
} from './featureToggleUtils.js';
|
||||
import {
|
||||
SettingScope,
|
||||
type LoadedSettings,
|
||||
type LoadableSettingScope,
|
||||
} from '../config/settings.js';
|
||||
|
||||
function createMockLoadedSettings(opts: {
|
||||
userSettings?: Record<string, unknown>;
|
||||
workspaceSettings?: Record<string, unknown>;
|
||||
userPath?: string;
|
||||
workspacePath?: string;
|
||||
}): LoadedSettings {
|
||||
const scopes: Record<
|
||||
string,
|
||||
{ settings: Record<string, unknown>; path: string }
|
||||
> = {
|
||||
[SettingScope.User]: {
|
||||
settings: opts.userSettings ?? {},
|
||||
path: opts.userPath ?? '/home/user/.gemini/settings.json',
|
||||
},
|
||||
[SettingScope.Workspace]: {
|
||||
settings: opts.workspaceSettings ?? {},
|
||||
path: opts.workspacePath ?? '/project/.gemini/settings.json',
|
||||
},
|
||||
};
|
||||
|
||||
const mockSettings = {
|
||||
forScope: vi.fn((scope: LoadableSettingScope) => scopes[scope]),
|
||||
setValue: vi.fn(),
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
return mockSettings;
|
||||
}
|
||||
|
||||
function createMockStrategy(overrides?: {
|
||||
needsEnabling?: (
|
||||
settings: LoadedSettings,
|
||||
scope: LoadableSettingScope,
|
||||
featureName: string,
|
||||
) => boolean;
|
||||
isExplicitlyDisabled?: (
|
||||
settings: LoadedSettings,
|
||||
scope: LoadableSettingScope,
|
||||
featureName: string,
|
||||
) => boolean;
|
||||
}): FeatureToggleStrategy {
|
||||
return {
|
||||
needsEnabling: vi.fn(overrides?.needsEnabling ?? (() => false)),
|
||||
enable: vi.fn(),
|
||||
isExplicitlyDisabled: vi.fn(
|
||||
overrides?.isExplicitlyDisabled ?? (() => false),
|
||||
),
|
||||
disable: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('featureToggleUtils', () => {
|
||||
describe('enableFeature', () => {
|
||||
it('should return no-op when the feature is already enabled in all scopes', () => {
|
||||
const settings = createMockLoadedSettings({});
|
||||
const strategy = createMockStrategy({
|
||||
needsEnabling: () => false,
|
||||
});
|
||||
|
||||
const result = enableFeature(settings, 'my-feature', strategy);
|
||||
|
||||
expect(result.status).toBe('no-op');
|
||||
expect(result.action).toBe('enable');
|
||||
expect(result.featureName).toBe('my-feature');
|
||||
expect(result.modifiedScopes).toHaveLength(0);
|
||||
expect(result.alreadyInStateScopes).toHaveLength(2);
|
||||
expect(strategy.enable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should enable the feature when disabled in one scope', () => {
|
||||
const settings = createMockLoadedSettings({});
|
||||
const strategy = createMockStrategy({
|
||||
needsEnabling: (_s, scope) => scope === SettingScope.Workspace,
|
||||
});
|
||||
|
||||
const result = enableFeature(settings, 'my-feature', strategy);
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.action).toBe('enable');
|
||||
expect(result.modifiedScopes).toHaveLength(1);
|
||||
expect(result.modifiedScopes[0].scope).toBe(SettingScope.Workspace);
|
||||
expect(result.alreadyInStateScopes).toHaveLength(1);
|
||||
expect(result.alreadyInStateScopes[0].scope).toBe(SettingScope.User);
|
||||
expect(strategy.enable).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should enable the feature when disabled in both scopes', () => {
|
||||
const settings = createMockLoadedSettings({});
|
||||
const strategy = createMockStrategy({
|
||||
needsEnabling: () => true,
|
||||
});
|
||||
|
||||
const result = enableFeature(settings, 'my-feature', strategy);
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.action).toBe('enable');
|
||||
expect(result.modifiedScopes).toHaveLength(2);
|
||||
expect(result.alreadyInStateScopes).toHaveLength(0);
|
||||
expect(strategy.enable).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should include correct scope paths in the result', () => {
|
||||
const settings = createMockLoadedSettings({
|
||||
userPath: '/custom/user/path',
|
||||
workspacePath: '/custom/workspace/path',
|
||||
});
|
||||
const strategy = createMockStrategy({
|
||||
needsEnabling: () => true,
|
||||
});
|
||||
|
||||
const result = enableFeature(settings, 'my-feature', strategy);
|
||||
|
||||
const paths = result.modifiedScopes.map((s) => s.path);
|
||||
expect(paths).toContain('/custom/workspace/path');
|
||||
expect(paths).toContain('/custom/user/path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableFeature', () => {
|
||||
it('should return no-op when the feature is already disabled in the target scope', () => {
|
||||
const settings = createMockLoadedSettings({});
|
||||
const strategy = createMockStrategy({
|
||||
isExplicitlyDisabled: () => true,
|
||||
});
|
||||
|
||||
const result = disableFeature(
|
||||
settings,
|
||||
'my-feature',
|
||||
SettingScope.User,
|
||||
strategy,
|
||||
);
|
||||
|
||||
expect(result.status).toBe('no-op');
|
||||
expect(result.action).toBe('disable');
|
||||
expect(result.featureName).toBe('my-feature');
|
||||
expect(result.modifiedScopes).toHaveLength(0);
|
||||
expect(result.alreadyInStateScopes).toHaveLength(1);
|
||||
expect(strategy.disable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should disable the feature when it is enabled', () => {
|
||||
const settings = createMockLoadedSettings({});
|
||||
const strategy = createMockStrategy({
|
||||
isExplicitlyDisabled: () => false,
|
||||
});
|
||||
|
||||
const result = disableFeature(
|
||||
settings,
|
||||
'my-feature',
|
||||
SettingScope.User,
|
||||
strategy,
|
||||
);
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.action).toBe('disable');
|
||||
expect(result.modifiedScopes).toHaveLength(1);
|
||||
expect(result.modifiedScopes[0].scope).toBe(SettingScope.User);
|
||||
expect(strategy.disable).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should return error for an invalid scope', () => {
|
||||
const settings = createMockLoadedSettings({});
|
||||
const strategy = createMockStrategy();
|
||||
|
||||
const result = disableFeature(
|
||||
settings,
|
||||
'my-feature',
|
||||
SettingScope.Session,
|
||||
strategy,
|
||||
);
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.action).toBe('disable');
|
||||
expect(result.error).toContain('Invalid settings scope');
|
||||
expect(strategy.disable).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
SettingScope,
|
||||
isLoadableSettingScope,
|
||||
type LoadableSettingScope,
|
||||
type LoadedSettings,
|
||||
} from '../config/settings.js';
|
||||
|
||||
export interface ModifiedScope {
|
||||
scope: SettingScope;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type FeatureActionStatus = 'success' | 'no-op' | 'error';
|
||||
|
||||
export interface FeatureActionResult {
|
||||
status: FeatureActionStatus;
|
||||
featureName: string;
|
||||
action: 'enable' | 'disable';
|
||||
/** Scopes where the feature's state was actually changed. */
|
||||
modifiedScopes: ModifiedScope[];
|
||||
/** Scopes where the feature was already in the desired state. */
|
||||
alreadyInStateScopes: ModifiedScope[];
|
||||
/** Error message if status is 'error'. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strategy pattern to handle differences between feature types (e.g. skills vs agents).
|
||||
*/
|
||||
export interface FeatureToggleStrategy {
|
||||
/**
|
||||
* Checks if the feature needs to be enabled in the given scope.
|
||||
* For skills (blacklist): returns true if in disabled list.
|
||||
* For agents (whitelist): returns true if NOT explicitly enabled (false or undefined).
|
||||
*/
|
||||
needsEnabling(
|
||||
settings: LoadedSettings,
|
||||
scope: LoadableSettingScope,
|
||||
featureName: string,
|
||||
): boolean;
|
||||
|
||||
/**
|
||||
* Applies the enable change to the settings object.
|
||||
*/
|
||||
enable(
|
||||
settings: LoadedSettings,
|
||||
scope: LoadableSettingScope,
|
||||
featureName: string,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Checks if the feature is explicitly disabled in the given scope.
|
||||
* For skills (blacklist): returns true if in disabled list.
|
||||
* For agents (whitelist): returns true if explicitly set to false.
|
||||
*/
|
||||
isExplicitlyDisabled(
|
||||
settings: LoadedSettings,
|
||||
scope: LoadableSettingScope,
|
||||
featureName: string,
|
||||
): boolean;
|
||||
|
||||
/**
|
||||
* Applies the disable change to the settings object.
|
||||
*/
|
||||
disable(
|
||||
settings: LoadedSettings,
|
||||
scope: LoadableSettingScope,
|
||||
featureName: string,
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables a feature by ensuring it is enabled in all writable scopes.
|
||||
*/
|
||||
export function enableFeature(
|
||||
settings: LoadedSettings,
|
||||
featureName: string,
|
||||
strategy: FeatureToggleStrategy,
|
||||
): FeatureActionResult {
|
||||
const writableScopes = [SettingScope.Workspace, SettingScope.User];
|
||||
const foundInDisabledScopes: ModifiedScope[] = [];
|
||||
const alreadyEnabledScopes: ModifiedScope[] = [];
|
||||
|
||||
for (const scope of writableScopes) {
|
||||
if (isLoadableSettingScope(scope)) {
|
||||
const scopePath = settings.forScope(scope).path;
|
||||
if (strategy.needsEnabling(settings, scope, featureName)) {
|
||||
foundInDisabledScopes.push({ scope, path: scopePath });
|
||||
} else {
|
||||
alreadyEnabledScopes.push({ scope, path: scopePath });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundInDisabledScopes.length === 0) {
|
||||
return {
|
||||
status: 'no-op',
|
||||
featureName,
|
||||
action: 'enable',
|
||||
modifiedScopes: [],
|
||||
alreadyInStateScopes: alreadyEnabledScopes,
|
||||
};
|
||||
}
|
||||
|
||||
const modifiedScopes: ModifiedScope[] = [];
|
||||
for (const { scope, path } of foundInDisabledScopes) {
|
||||
if (isLoadableSettingScope(scope)) {
|
||||
strategy.enable(settings, scope, featureName);
|
||||
modifiedScopes.push({ scope, path });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
featureName,
|
||||
action: 'enable',
|
||||
modifiedScopes,
|
||||
alreadyInStateScopes: alreadyEnabledScopes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables a feature in the specified scope.
|
||||
*/
|
||||
export function disableFeature(
|
||||
settings: LoadedSettings,
|
||||
featureName: string,
|
||||
scope: SettingScope,
|
||||
strategy: FeatureToggleStrategy,
|
||||
): FeatureActionResult {
|
||||
if (!isLoadableSettingScope(scope)) {
|
||||
return {
|
||||
status: 'error',
|
||||
featureName,
|
||||
action: 'disable',
|
||||
modifiedScopes: [],
|
||||
alreadyInStateScopes: [],
|
||||
error: `Invalid settings scope: ${scope}`,
|
||||
};
|
||||
}
|
||||
|
||||
const scopePath = settings.forScope(scope).path;
|
||||
|
||||
if (strategy.isExplicitlyDisabled(settings, scope, featureName)) {
|
||||
return {
|
||||
status: 'no-op',
|
||||
featureName,
|
||||
action: 'disable',
|
||||
modifiedScopes: [],
|
||||
alreadyInStateScopes: [{ scope, path: scopePath }],
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's already disabled in the other writable scope
|
||||
const otherScope =
|
||||
scope === SettingScope.Workspace
|
||||
? SettingScope.User
|
||||
: SettingScope.Workspace;
|
||||
const alreadyDisabledInOther: ModifiedScope[] = [];
|
||||
|
||||
if (isLoadableSettingScope(otherScope)) {
|
||||
if (strategy.isExplicitlyDisabled(settings, otherScope, featureName)) {
|
||||
alreadyDisabledInOther.push({
|
||||
scope: otherScope,
|
||||
path: settings.forScope(otherScope).path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
strategy.disable(settings, scope, featureName);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
featureName,
|
||||
action: 'disable',
|
||||
modifiedScopes: [{ scope, path: scopePath }],
|
||||
alreadyInStateScopes: alreadyDisabledInOther,
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,13 @@ import type { UpdateObject } from '../ui/utils/updateCheck.js';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import EventEmitter from 'node:events';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { handleAutoUpdate, setUpdateHandler } from './handleAutoUpdate.js';
|
||||
import {
|
||||
handleAutoUpdate,
|
||||
setUpdateHandler,
|
||||
isUpdateInProgress,
|
||||
waitForUpdateCompletion,
|
||||
_setUpdateStateForTesting,
|
||||
} from './handleAutoUpdate.js';
|
||||
import { MessageType } from '../ui/types.js';
|
||||
|
||||
vi.mock('./installationInfo.js', async () => {
|
||||
@@ -79,6 +85,7 @@ describe('handleAutoUpdate', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
_setUpdateStateForTesting(false);
|
||||
});
|
||||
|
||||
it('should do nothing if update info is null', () => {
|
||||
@@ -88,6 +95,80 @@ describe('handleAutoUpdate', () => {
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should track update progress state', async () => {
|
||||
mockGetInstallationInfo.mockReturnValue({
|
||||
updateCommand: 'npm i -g @google/gemini-cli@latest',
|
||||
updateMessage: 'This is an additional message.',
|
||||
isGlobal: false,
|
||||
packageManager: PackageManager.NPM,
|
||||
});
|
||||
|
||||
expect(isUpdateInProgress()).toBe(false);
|
||||
|
||||
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
|
||||
|
||||
expect(isUpdateInProgress()).toBe(true);
|
||||
|
||||
mockChildProcess.emit('close', 0);
|
||||
|
||||
expect(isUpdateInProgress()).toBe(false);
|
||||
});
|
||||
|
||||
it('should track update progress state on error', async () => {
|
||||
mockGetInstallationInfo.mockReturnValue({
|
||||
updateCommand: 'npm i -g @google/gemini-cli@latest',
|
||||
updateMessage: 'This is an additional message.',
|
||||
isGlobal: false,
|
||||
packageManager: PackageManager.NPM,
|
||||
});
|
||||
|
||||
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
|
||||
|
||||
expect(isUpdateInProgress()).toBe(true);
|
||||
|
||||
mockChildProcess.emit('error', new Error('fail'));
|
||||
|
||||
expect(isUpdateInProgress()).toBe(false);
|
||||
});
|
||||
|
||||
it('should resolve waitForUpdateCompletion when update succeeds', async () => {
|
||||
_setUpdateStateForTesting(true);
|
||||
|
||||
const waitPromise = waitForUpdateCompletion();
|
||||
updateEventEmitter.emit('update-success', {});
|
||||
|
||||
await expect(waitPromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should resolve waitForUpdateCompletion when update fails', async () => {
|
||||
_setUpdateStateForTesting(true);
|
||||
|
||||
const waitPromise = waitForUpdateCompletion();
|
||||
updateEventEmitter.emit('update-failed', {});
|
||||
|
||||
await expect(waitPromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should resolve waitForUpdateCompletion immediately if not in progress', async () => {
|
||||
_setUpdateStateForTesting(false);
|
||||
|
||||
const waitPromise = waitForUpdateCompletion();
|
||||
|
||||
await expect(waitPromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should timeout waitForUpdateCompletion', async () => {
|
||||
vi.useFakeTimers();
|
||||
_setUpdateStateForTesting(true);
|
||||
|
||||
const waitPromise = waitForUpdateCompletion(1000);
|
||||
|
||||
vi.advanceTimersByTime(1001);
|
||||
|
||||
await expect(waitPromise).resolves.toBeUndefined();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should do nothing if update prompts are disabled', () => {
|
||||
mockSettings.merged.general.enableAutoUpdateNotification = false;
|
||||
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
|
||||
|
||||
@@ -12,6 +12,54 @@ import type { HistoryItem } from '../ui/types.js';
|
||||
import { MessageType } from '../ui/types.js';
|
||||
import { spawnWrapper } from './spawnWrapper.js';
|
||||
import type { spawn } from 'node:child_process';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
let _updateInProgress = false;
|
||||
|
||||
/** @internal */
|
||||
export function _setUpdateStateForTesting(value: boolean) {
|
||||
_updateInProgress = value;
|
||||
}
|
||||
|
||||
export function isUpdateInProgress() {
|
||||
return _updateInProgress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves when the update process completes or times out.
|
||||
*/
|
||||
export async function waitForUpdateCompletion(
|
||||
timeoutMs = 30000,
|
||||
): Promise<void> {
|
||||
if (!_updateInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugLogger.log(
|
||||
'\nGemini CLI is waiting for a background update to complete before restarting...',
|
||||
);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Re-check the condition inside the promise executor to avoid a race condition.
|
||||
// If the update finished between the initial check and now, resolve immediately.
|
||||
if (!_updateInProgress) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(cleanup, timeoutMs);
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(timer);
|
||||
updateEventEmitter.off('update-success', cleanup);
|
||||
updateEventEmitter.off('update-failed', cleanup);
|
||||
resolve();
|
||||
}
|
||||
|
||||
updateEventEmitter.once('update-success', cleanup);
|
||||
updateEventEmitter.once('update-failed', cleanup);
|
||||
});
|
||||
}
|
||||
|
||||
export function handleAutoUpdate(
|
||||
info: UpdateObject | null,
|
||||
@@ -62,6 +110,11 @@ export function handleAutoUpdate(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_updateInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isNightly = info.update.latest.includes('nightly');
|
||||
|
||||
const updateCommand = installationInfo.updateCommand.replace(
|
||||
@@ -73,10 +126,14 @@ export function handleAutoUpdate(
|
||||
shell: true,
|
||||
detached: true,
|
||||
});
|
||||
|
||||
_updateInProgress = true;
|
||||
|
||||
// Un-reference the child process to allow the parent to exit independently.
|
||||
updateProcess.unref();
|
||||
|
||||
updateProcess.on('close', (code) => {
|
||||
_updateInProgress = false;
|
||||
if (code === 0) {
|
||||
updateEventEmitter.emit('update-success', {
|
||||
message:
|
||||
@@ -90,6 +147,7 @@ export function handleAutoUpdate(
|
||||
});
|
||||
|
||||
updateProcess.on('error', (err) => {
|
||||
_updateInProgress = false;
|
||||
updateEventEmitter.emit('update-failed', {
|
||||
message: `Automatic update failed. Please try updating manually. (error: ${err.message})`,
|
||||
});
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
import { vi } from 'vitest';
|
||||
import { RELAUNCH_EXIT_CODE, relaunchApp } from './processUtils.js';
|
||||
import * as cleanup from './cleanup.js';
|
||||
import * as handleAutoUpdate from './handleAutoUpdate.js';
|
||||
|
||||
vi.mock('./handleAutoUpdate.js', () => ({
|
||||
waitForUpdateCompletion: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe('processUtils', () => {
|
||||
const processExit = vi
|
||||
@@ -14,8 +19,11 @@ describe('processUtils', () => {
|
||||
.mockReturnValue(undefined as never);
|
||||
const runExitCleanup = vi.spyOn(cleanup, 'runExitCleanup');
|
||||
|
||||
it('should run cleanup and exit with the relaunch code', async () => {
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it('should wait for updates, run cleanup, and exit with the relaunch code', async () => {
|
||||
await relaunchApp();
|
||||
expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1);
|
||||
expect(runExitCleanup).toHaveBeenCalledTimes(1);
|
||||
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { runExitCleanup } from './cleanup.js';
|
||||
import { waitForUpdateCompletion } from './handleAutoUpdate.js';
|
||||
|
||||
/**
|
||||
* Exit code used to signal that the CLI should be relaunched.
|
||||
@@ -15,6 +16,7 @@ export const RELAUNCH_EXIT_CODE = 199;
|
||||
* Exits the process with a special code to signal that the parent process should relaunch it.
|
||||
*/
|
||||
export async function relaunchApp(): Promise<void> {
|
||||
await waitForUpdateCompletion();
|
||||
await runExitCleanup();
|
||||
process.exit(RELAUNCH_EXIT_CODE);
|
||||
}
|
||||
|
||||
@@ -919,6 +919,32 @@ describe('Session Cleanup', () => {
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete the session-specific directory', async () => {
|
||||
const config = createMockConfig();
|
||||
const settings: Settings = {
|
||||
general: {
|
||||
sessionRetention: {
|
||||
enabled: true,
|
||||
maxAge: '1d', // Very short retention to trigger deletion of all but current
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Mock successful file operations
|
||||
mockFs.access.mockResolvedValue(undefined);
|
||||
mockFs.unlink.mockResolvedValue(undefined);
|
||||
mockFs.rm.mockResolvedValue(undefined);
|
||||
|
||||
await cleanupExpiredSessions(config, settings);
|
||||
|
||||
// Verify that fs.rm was called with the session directory for the deleted session that has sessionInfo
|
||||
// recent456 should be deleted and its directory removed
|
||||
expect(mockFs.rm).toHaveBeenCalledWith(
|
||||
path.join('/tmp/test-project', 'recent456'),
|
||||
expect.objectContaining({ recursive: true, force: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRetentionPeriod format validation', () => {
|
||||
|
||||
@@ -115,6 +115,17 @@ export async function cleanupExpiredSessions(
|
||||
} catch {
|
||||
/* ignore if doesn't exist */
|
||||
}
|
||||
|
||||
// ALSO cleanup the session-specific directory (contains plans, tasks, etc.)
|
||||
const sessionDir = path.join(
|
||||
config.storage.getProjectTempDir(),
|
||||
sessionId,
|
||||
);
|
||||
try {
|
||||
await fs.rm(sessionDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore if doesn't exist */
|
||||
}
|
||||
}
|
||||
|
||||
if (config.getDebugMode()) {
|
||||
|
||||
@@ -735,5 +735,59 @@ describe('SettingsUtils', () => {
|
||||
expect(result).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDisplayValue with units', () => {
|
||||
it('should format percentage correctly when unit is %', () => {
|
||||
vi.mocked(getSettingsSchema).mockReturnValue({
|
||||
model: {
|
||||
properties: {
|
||||
compressionThreshold: {
|
||||
type: 'number',
|
||||
label: 'Context Compression Threshold',
|
||||
category: 'Model',
|
||||
requiresRestart: true,
|
||||
default: 0.5,
|
||||
unit: '%',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as SettingsSchemaType);
|
||||
|
||||
const settings = makeMockSettings({
|
||||
model: { compressionThreshold: 0.8 },
|
||||
});
|
||||
const result = getDisplayValue(
|
||||
'model.compressionThreshold',
|
||||
settings,
|
||||
makeMockSettings({}),
|
||||
);
|
||||
expect(result).toBe('0.8 (80%)*');
|
||||
});
|
||||
|
||||
it('should append unit for non-% units', () => {
|
||||
vi.mocked(getSettingsSchema).mockReturnValue({
|
||||
ui: {
|
||||
properties: {
|
||||
pollingInterval: {
|
||||
type: 'number',
|
||||
label: 'Polling Interval',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: 60,
|
||||
unit: 's',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as SettingsSchemaType);
|
||||
|
||||
const settings = makeMockSettings({ ui: { pollingInterval: 30 } });
|
||||
const result = getDisplayValue(
|
||||
'ui.pollingInterval',
|
||||
settings,
|
||||
makeMockSettings({}),
|
||||
);
|
||||
expect(result).toBe('30s*');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,7 +84,7 @@ export function getDefaultValue(key: string): SettingsValue {
|
||||
|
||||
/**
|
||||
* Get the effective default value for a setting, checking experiment values when available.
|
||||
* For settings like compressionThreshold, this will return the experiment value if set,
|
||||
* For settings like Context Compression Threshold, this will return the experiment value if set,
|
||||
* otherwise falls back to the schema default.
|
||||
*/
|
||||
export function getEffectiveDefaultValue(
|
||||
@@ -289,6 +289,11 @@ export function getDisplayValue(
|
||||
valueString = option?.label ?? `${value}`;
|
||||
}
|
||||
|
||||
if (definition?.unit === '%' && typeof value === 'number') {
|
||||
valueString = `${value} (${Math.round(value * 100)}%)`;
|
||||
} else if (definition?.unit) {
|
||||
valueString = `${valueString}${definition.unit}`;
|
||||
}
|
||||
if (existsInScope) {
|
||||
return `${valueString}*`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
SettingScope,
|
||||
type LoadedSettings,
|
||||
type LoadableSettingScope,
|
||||
} from '../config/settings.js';
|
||||
import { enableSkill, disableSkill } from './skillSettings.js';
|
||||
|
||||
function createMockLoadedSettings(opts: {
|
||||
userSettings?: Record<string, unknown>;
|
||||
workspaceSettings?: Record<string, unknown>;
|
||||
userPath?: string;
|
||||
workspacePath?: string;
|
||||
}): LoadedSettings {
|
||||
const scopes: Record<
|
||||
string,
|
||||
{
|
||||
settings: Record<string, unknown>;
|
||||
originalSettings: Record<string, unknown>;
|
||||
path: string;
|
||||
}
|
||||
> = {
|
||||
[SettingScope.User]: {
|
||||
settings: opts.userSettings ?? {},
|
||||
originalSettings: opts.userSettings ?? {},
|
||||
path: opts.userPath ?? '/home/user/.gemini/settings.json',
|
||||
},
|
||||
[SettingScope.Workspace]: {
|
||||
settings: opts.workspaceSettings ?? {},
|
||||
originalSettings: opts.workspaceSettings ?? {},
|
||||
path: opts.workspacePath ?? '/project/.gemini/settings.json',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
forScope: vi.fn((scope: LoadableSettingScope) => scopes[scope]),
|
||||
setValue: vi.fn(),
|
||||
} as unknown as LoadedSettings;
|
||||
}
|
||||
|
||||
describe('skillSettings', () => {
|
||||
describe('skillStrategy (via enableSkill / disableSkill)', () => {
|
||||
describe('enableSkill', () => {
|
||||
it('should return no-op when the skill is not in any disabled list', () => {
|
||||
const settings = createMockLoadedSettings({
|
||||
userSettings: { skills: { disabled: [] } },
|
||||
workspaceSettings: { skills: { disabled: [] } },
|
||||
});
|
||||
|
||||
const result = enableSkill(settings, 'my-skill');
|
||||
|
||||
expect(result.status).toBe('no-op');
|
||||
expect(result.action).toBe('enable');
|
||||
expect(result.skillName).toBe('my-skill');
|
||||
expect(result.modifiedScopes).toHaveLength(0);
|
||||
expect(settings.setValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return no-op when skills.disabled is undefined', () => {
|
||||
const settings = createMockLoadedSettings({
|
||||
userSettings: {},
|
||||
workspaceSettings: {},
|
||||
});
|
||||
|
||||
const result = enableSkill(settings, 'my-skill');
|
||||
|
||||
expect(result.status).toBe('no-op');
|
||||
expect(result.action).toBe('enable');
|
||||
expect(result.modifiedScopes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should enable the skill when it is in the disabled list of one scope', () => {
|
||||
const settings = createMockLoadedSettings({
|
||||
userSettings: { skills: { disabled: ['my-skill'] } },
|
||||
workspaceSettings: { skills: { disabled: [] } },
|
||||
});
|
||||
|
||||
const result = enableSkill(settings, 'my-skill');
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.action).toBe('enable');
|
||||
expect(result.modifiedScopes).toHaveLength(1);
|
||||
expect(result.modifiedScopes[0].scope).toBe(SettingScope.User);
|
||||
expect(result.alreadyInStateScopes).toHaveLength(1);
|
||||
expect(result.alreadyInStateScopes[0].scope).toBe(
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
expect(settings.setValue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should enable the skill when it is in the disabled list of both scopes', () => {
|
||||
const settings = createMockLoadedSettings({
|
||||
userSettings: { skills: { disabled: ['my-skill', 'other-skill'] } },
|
||||
workspaceSettings: { skills: { disabled: ['my-skill'] } },
|
||||
});
|
||||
|
||||
const result = enableSkill(settings, 'my-skill');
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.modifiedScopes).toHaveLength(2);
|
||||
expect(result.alreadyInStateScopes).toHaveLength(0);
|
||||
expect(settings.setValue).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should not affect other skills in the disabled list', () => {
|
||||
const settings = createMockLoadedSettings({
|
||||
userSettings: { skills: { disabled: ['my-skill', 'keep-disabled'] } },
|
||||
workspaceSettings: { skills: { disabled: [] } },
|
||||
});
|
||||
|
||||
const result = enableSkill(settings, 'my-skill');
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(settings.setValue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableSkill', () => {
|
||||
it('should return no-op when the skill is already in the disabled list', () => {
|
||||
const settings = createMockLoadedSettings({
|
||||
userSettings: { skills: { disabled: ['my-skill'] } },
|
||||
});
|
||||
|
||||
const result = disableSkill(settings, 'my-skill', SettingScope.User);
|
||||
|
||||
expect(result.status).toBe('no-op');
|
||||
expect(result.action).toBe('disable');
|
||||
expect(result.skillName).toBe('my-skill');
|
||||
expect(result.modifiedScopes).toHaveLength(0);
|
||||
expect(result.alreadyInStateScopes).toHaveLength(1);
|
||||
expect(settings.setValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should disable the skill when it is not in the disabled list', () => {
|
||||
const settings = createMockLoadedSettings({
|
||||
userSettings: { skills: { disabled: [] } },
|
||||
});
|
||||
|
||||
const result = disableSkill(settings, 'my-skill', SettingScope.User);
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.action).toBe('disable');
|
||||
expect(result.modifiedScopes).toHaveLength(1);
|
||||
expect(result.modifiedScopes[0].scope).toBe(SettingScope.User);
|
||||
expect(settings.setValue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should disable the skill when skills.disabled is undefined', () => {
|
||||
const settings = createMockLoadedSettings({
|
||||
userSettings: {},
|
||||
});
|
||||
|
||||
const result = disableSkill(settings, 'my-skill', SettingScope.User);
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.action).toBe('disable');
|
||||
expect(result.modifiedScopes).toHaveLength(1);
|
||||
expect(settings.setValue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return error for an invalid scope', () => {
|
||||
const settings = createMockLoadedSettings({});
|
||||
|
||||
const result = disableSkill(settings, 'my-skill', SettingScope.Session);
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.error).toContain('Invalid settings scope');
|
||||
});
|
||||
|
||||
it('should disable in workspace and report user as already disabled', () => {
|
||||
const settings = createMockLoadedSettings({
|
||||
userSettings: { skills: { disabled: ['my-skill'] } },
|
||||
workspaceSettings: { skills: { disabled: [] } },
|
||||
});
|
||||
|
||||
const result = disableSkill(
|
||||
settings,
|
||||
'my-skill',
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.modifiedScopes).toHaveLength(1);
|
||||
expect(result.modifiedScopes[0].scope).toBe(SettingScope.Workspace);
|
||||
expect(result.alreadyInStateScopes).toHaveLength(1);
|
||||
expect(result.alreadyInStateScopes[0].scope).toBe(SettingScope.User);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,34 +4,58 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
SettingScope,
|
||||
isLoadableSettingScope,
|
||||
type LoadedSettings,
|
||||
} from '../config/settings.js';
|
||||
import type { SettingScope, LoadedSettings } from '../config/settings.js';
|
||||
|
||||
export interface ModifiedScope {
|
||||
scope: SettingScope;
|
||||
path: string;
|
||||
}
|
||||
import {
|
||||
type FeatureActionResult,
|
||||
type FeatureToggleStrategy,
|
||||
enableFeature,
|
||||
disableFeature,
|
||||
} from './featureToggleUtils.js';
|
||||
|
||||
export type { ModifiedScope } from './featureToggleUtils.js';
|
||||
|
||||
export type SkillActionStatus = 'success' | 'no-op' | 'error';
|
||||
|
||||
/**
|
||||
* Metadata representing the result of a skill settings operation.
|
||||
*/
|
||||
export interface SkillActionResult {
|
||||
status: SkillActionStatus;
|
||||
export interface SkillActionResult
|
||||
extends Omit<FeatureActionResult, 'featureName'> {
|
||||
skillName: string;
|
||||
action: 'enable' | 'disable';
|
||||
/** Scopes where the skill's state was actually changed. */
|
||||
modifiedScopes: ModifiedScope[];
|
||||
/** Scopes where the skill was already in the desired state. */
|
||||
alreadyInStateScopes: ModifiedScope[];
|
||||
/** Error message if status is 'error'. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const skillStrategy: FeatureToggleStrategy = {
|
||||
needsEnabling: (settings, scope, skillName) => {
|
||||
const scopeDisabled = settings.forScope(scope).settings.skills?.disabled;
|
||||
return !!scopeDisabled?.includes(skillName);
|
||||
},
|
||||
enable: (settings, scope, skillName) => {
|
||||
const currentScopeDisabled =
|
||||
settings.forScope(scope).settings.skills?.disabled ?? [];
|
||||
const newDisabled = currentScopeDisabled.filter(
|
||||
(name) => name !== skillName,
|
||||
);
|
||||
settings.setValue(scope, 'skills.disabled', newDisabled);
|
||||
},
|
||||
isExplicitlyDisabled: (settings, scope, skillName) => {
|
||||
const currentScopeDisabled =
|
||||
settings.forScope(scope).settings.skills?.disabled ?? [];
|
||||
return currentScopeDisabled.includes(skillName);
|
||||
},
|
||||
disable: (settings, scope, skillName) => {
|
||||
const currentScopeDisabled =
|
||||
settings.forScope(scope).settings.skills?.disabled ?? [];
|
||||
// The generic utility checks isExplicitlyDisabled before calling this,
|
||||
// but just to be safe and idempotent, we check or we assume the utility did its job.
|
||||
// The utility does check isExplicitlyDisabled first.
|
||||
// So we can blindly add it, but since we are modifying an array, pushing is fine.
|
||||
// However, if we assume purely that we must disable it:
|
||||
const newDisabled = [...currentScopeDisabled, skillName];
|
||||
settings.setValue(scope, 'skills.disabled', newDisabled);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Enables a skill by removing it from all writable disabled lists (User and Workspace).
|
||||
*/
|
||||
@@ -39,51 +63,14 @@ export function enableSkill(
|
||||
settings: LoadedSettings,
|
||||
skillName: string,
|
||||
): SkillActionResult {
|
||||
const writableScopes = [SettingScope.Workspace, SettingScope.User];
|
||||
const foundInDisabledScopes: ModifiedScope[] = [];
|
||||
const alreadyEnabledScopes: ModifiedScope[] = [];
|
||||
|
||||
for (const scope of writableScopes) {
|
||||
if (isLoadableSettingScope(scope)) {
|
||||
const scopePath = settings.forScope(scope).path;
|
||||
const scopeDisabled = settings.forScope(scope).settings.skills?.disabled;
|
||||
if (scopeDisabled?.includes(skillName)) {
|
||||
foundInDisabledScopes.push({ scope, path: scopePath });
|
||||
} else {
|
||||
alreadyEnabledScopes.push({ scope, path: scopePath });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundInDisabledScopes.length === 0) {
|
||||
return {
|
||||
status: 'no-op',
|
||||
skillName,
|
||||
action: 'enable',
|
||||
modifiedScopes: [],
|
||||
alreadyInStateScopes: alreadyEnabledScopes,
|
||||
};
|
||||
}
|
||||
|
||||
const modifiedScopes: ModifiedScope[] = [];
|
||||
for (const { scope, path } of foundInDisabledScopes) {
|
||||
if (isLoadableSettingScope(scope)) {
|
||||
const currentScopeDisabled =
|
||||
settings.forScope(scope).settings.skills?.disabled ?? [];
|
||||
const newDisabled = currentScopeDisabled.filter(
|
||||
(name) => name !== skillName,
|
||||
);
|
||||
settings.setValue(scope, 'skills.disabled', newDisabled);
|
||||
modifiedScopes.push({ scope, path });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
const { featureName, ...rest } = enableFeature(
|
||||
settings,
|
||||
skillName,
|
||||
action: 'enable',
|
||||
modifiedScopes,
|
||||
alreadyInStateScopes: alreadyEnabledScopes,
|
||||
skillStrategy,
|
||||
);
|
||||
return {
|
||||
...rest,
|
||||
skillName: featureName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,57 +82,14 @@ export function disableSkill(
|
||||
skillName: string,
|
||||
scope: SettingScope,
|
||||
): SkillActionResult {
|
||||
if (!isLoadableSettingScope(scope)) {
|
||||
return {
|
||||
status: 'error',
|
||||
skillName,
|
||||
action: 'disable',
|
||||
modifiedScopes: [],
|
||||
alreadyInStateScopes: [],
|
||||
error: `Invalid settings scope: ${scope}`,
|
||||
};
|
||||
}
|
||||
|
||||
const scopePath = settings.forScope(scope).path;
|
||||
const currentScopeDisabled =
|
||||
settings.forScope(scope).settings.skills?.disabled ?? [];
|
||||
|
||||
if (currentScopeDisabled.includes(skillName)) {
|
||||
return {
|
||||
status: 'no-op',
|
||||
skillName,
|
||||
action: 'disable',
|
||||
modifiedScopes: [],
|
||||
alreadyInStateScopes: [{ scope, path: scopePath }],
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's already disabled in the other writable scope
|
||||
const otherScope =
|
||||
scope === SettingScope.Workspace
|
||||
? SettingScope.User
|
||||
: SettingScope.Workspace;
|
||||
const alreadyDisabledInOther: ModifiedScope[] = [];
|
||||
|
||||
if (isLoadableSettingScope(otherScope)) {
|
||||
const otherScopeDisabled =
|
||||
settings.forScope(otherScope).settings.skills?.disabled;
|
||||
if (otherScopeDisabled?.includes(skillName)) {
|
||||
alreadyDisabledInOther.push({
|
||||
scope: otherScope,
|
||||
path: settings.forScope(otherScope).path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newDisabled = [...currentScopeDisabled, skillName];
|
||||
settings.setValue(scope, 'skills.disabled', newDisabled);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
const { featureName, ...rest } = disableFeature(
|
||||
settings,
|
||||
skillName,
|
||||
action: 'disable',
|
||||
modifiedScopes: [{ scope, path: scopePath }],
|
||||
alreadyInStateScopes: alreadyDisabledInOther,
|
||||
scope,
|
||||
skillStrategy,
|
||||
);
|
||||
return {
|
||||
...rest,
|
||||
skillName: featureName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,6 +93,10 @@ describe('GeminiAgent Session Resume', () => {
|
||||
},
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(false),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Mocked<Config>;
|
||||
mockSettings = {
|
||||
merged: {
|
||||
@@ -203,6 +207,10 @@ describe('GeminiAgent Session Resume', () => {
|
||||
],
|
||||
currentModeId: ApprovalMode.DEFAULT,
|
||||
},
|
||||
models: {
|
||||
availableModels: expect.any(Array) as unknown,
|
||||
currentModelId: 'gemini-pro',
|
||||
},
|
||||
});
|
||||
|
||||
// Verify resumeChat received the correct arguments
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandHandler } from './commandHandler.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('CommandHandler', () => {
|
||||
it('parses commands correctly', () => {
|
||||
const handler = new CommandHandler();
|
||||
// @ts-expect-error - testing private method
|
||||
const parse = (query: string) => handler.parseSlashCommand(query);
|
||||
|
||||
const memShow = parse('/memory show');
|
||||
expect(memShow.commandToExecute?.name).toBe('memory show');
|
||||
expect(memShow.args).toBe('');
|
||||
|
||||
const memAdd = parse('/memory add hello world');
|
||||
expect(memAdd.commandToExecute?.name).toBe('memory add');
|
||||
expect(memAdd.args).toBe('hello world');
|
||||
|
||||
const extList = parse('/extensions list');
|
||||
expect(extList.commandToExecute?.name).toBe('extensions list');
|
||||
|
||||
const init = parse('/init');
|
||||
expect(init.commandToExecute?.name).toBe('init');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Command, CommandContext } from './commands/types.js';
|
||||
import { CommandRegistry } from './commands/commandRegistry.js';
|
||||
import { MemoryCommand } from './commands/memory.js';
|
||||
import { ExtensionsCommand } from './commands/extensions.js';
|
||||
import { InitCommand } from './commands/init.js';
|
||||
import { RestoreCommand } from './commands/restore.js';
|
||||
|
||||
export class CommandHandler {
|
||||
private registry: CommandRegistry;
|
||||
|
||||
constructor() {
|
||||
this.registry = CommandHandler.createRegistry();
|
||||
}
|
||||
|
||||
private static createRegistry(): CommandRegistry {
|
||||
const registry = new CommandRegistry();
|
||||
registry.register(new MemoryCommand());
|
||||
registry.register(new ExtensionsCommand());
|
||||
registry.register(new InitCommand());
|
||||
registry.register(new RestoreCommand());
|
||||
return registry;
|
||||
}
|
||||
|
||||
getAvailableCommands(): Array<{ name: string; description: string }> {
|
||||
return this.registry.getAllCommands().map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and executes a command string if it matches a registered command.
|
||||
* Returns true if a command was handled, false otherwise.
|
||||
*/
|
||||
async handleCommand(
|
||||
commandText: string,
|
||||
context: CommandContext,
|
||||
): Promise<boolean> {
|
||||
const { commandToExecute, args } = this.parseSlashCommand(commandText);
|
||||
|
||||
if (commandToExecute) {
|
||||
await this.runCommand(commandToExecute, args, context);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async runCommand(
|
||||
commandToExecute: Command,
|
||||
args: string,
|
||||
context: CommandContext,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await commandToExecute.execute(
|
||||
context,
|
||||
args ? args.split(/\s+/) : [],
|
||||
);
|
||||
|
||||
let messageContent = '';
|
||||
if (typeof result.data === 'string') {
|
||||
messageContent = result.data;
|
||||
} else if (
|
||||
typeof result.data === 'object' &&
|
||||
result.data !== null &&
|
||||
'content' in result.data
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
|
||||
messageContent = (result.data as Record<string, any>)[
|
||||
'content'
|
||||
] as string;
|
||||
} else {
|
||||
messageContent = JSON.stringify(result.data, null, 2);
|
||||
}
|
||||
|
||||
await context.sendMessage(messageContent);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
await context.sendMessage(`Error: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a raw slash command string into its matching headless command and arguments.
|
||||
* Mirrors `packages/cli/src/utils/commands.ts` logic.
|
||||
*/
|
||||
private parseSlashCommand(query: string): {
|
||||
commandToExecute: Command | undefined;
|
||||
args: string;
|
||||
} {
|
||||
const trimmed = query.trim();
|
||||
const parts = trimmed.substring(1).trim().split(/\s+/);
|
||||
const commandPath = parts.filter((p) => p);
|
||||
|
||||
let currentCommands = this.registry.getAllCommands();
|
||||
let commandToExecute: Command | undefined;
|
||||
let pathIndex = 0;
|
||||
|
||||
for (const part of commandPath) {
|
||||
const foundCommand = currentCommands.find((cmd) => {
|
||||
const expectedName = commandPath.slice(0, pathIndex + 1).join(' ');
|
||||
return (
|
||||
cmd.name === part ||
|
||||
cmd.name === expectedName ||
|
||||
cmd.aliases?.includes(part) ||
|
||||
cmd.aliases?.includes(expectedName)
|
||||
);
|
||||
});
|
||||
|
||||
if (foundCommand) {
|
||||
commandToExecute = foundCommand;
|
||||
pathIndex++;
|
||||
if (foundCommand.subCommands) {
|
||||
currentCommands = foundCommand.subCommands;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const args = parts.slice(pathIndex).join(' ');
|
||||
|
||||
return { commandToExecute, args };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import type { Command } from './types.js';
|
||||
|
||||
export class CommandRegistry {
|
||||
private readonly commands = new Map<string, Command>();
|
||||
|
||||
register(command: Command) {
|
||||
if (this.commands.has(command.name)) {
|
||||
debugLogger.warn(`Command ${command.name} already registered. Skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.commands.set(command.name, command);
|
||||
|
||||
for (const subCommand of command.subCommands ?? []) {
|
||||
this.register(subCommand);
|
||||
}
|
||||
}
|
||||
|
||||
get(commandName: string): Command | undefined {
|
||||
return this.commands.get(commandName);
|
||||
}
|
||||
|
||||
getAllCommands(): Command[] {
|
||||
return [...this.commands.values()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { listExtensions } from '@google/gemini-cli-core';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import {
|
||||
ExtensionManager,
|
||||
inferInstallMetadata,
|
||||
} from '../../config/extension-manager.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandExecutionResponse,
|
||||
} from './types.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
export class ExtensionsCommand implements Command {
|
||||
readonly name = 'extensions';
|
||||
readonly description = 'Manage extensions.';
|
||||
readonly subCommands = [
|
||||
new ListExtensionsCommand(),
|
||||
new ExploreExtensionsCommand(),
|
||||
new EnableExtensionCommand(),
|
||||
new DisableExtensionCommand(),
|
||||
new InstallExtensionCommand(),
|
||||
new LinkExtensionCommand(),
|
||||
new UninstallExtensionCommand(),
|
||||
new RestartExtensionCommand(),
|
||||
new UpdateExtensionCommand(),
|
||||
];
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
return new ListExtensionsCommand().execute(context, _);
|
||||
}
|
||||
}
|
||||
|
||||
export class ListExtensionsCommand implements Command {
|
||||
readonly name = 'extensions list';
|
||||
readonly description = 'Lists all installed extensions.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const extensions = listExtensions(context.config);
|
||||
const data = extensions.length ? extensions : 'No extensions installed.';
|
||||
|
||||
return { name: this.name, data };
|
||||
}
|
||||
}
|
||||
|
||||
export class ExploreExtensionsCommand implements Command {
|
||||
readonly name = 'extensions explore';
|
||||
readonly description = 'Explore available extensions.';
|
||||
|
||||
async execute(
|
||||
_context: CommandContext,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const extensionsUrl = 'https://geminicli.com/extensions/';
|
||||
return {
|
||||
name: this.name,
|
||||
data: `View or install available extensions at ${extensionsUrl}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getEnableDisableContext(
|
||||
config: Config,
|
||||
args: string[],
|
||||
invocationName: string,
|
||||
) {
|
||||
const extensionManager = config.getExtensionLoader();
|
||||
if (!(extensionManager instanceof ExtensionManager)) {
|
||||
return {
|
||||
error: `Cannot ${invocationName} extensions in this environment.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (args.length === 0) {
|
||||
return {
|
||||
error: `Usage: /extensions ${invocationName} <extension> [--scope=<user|workspace|session>]`,
|
||||
};
|
||||
}
|
||||
|
||||
let scope = SettingScope.User;
|
||||
if (args.includes('--scope=workspace') || args.includes('workspace')) {
|
||||
scope = SettingScope.Workspace;
|
||||
} else if (args.includes('--scope=session') || args.includes('session')) {
|
||||
scope = SettingScope.Session;
|
||||
}
|
||||
|
||||
const name = args.filter(
|
||||
(a) =>
|
||||
!a.startsWith('--scope') && !['user', 'workspace', 'session'].includes(a),
|
||||
)[0];
|
||||
|
||||
let names: string[] = [];
|
||||
if (name === '--all') {
|
||||
let extensions = extensionManager.getExtensions();
|
||||
if (invocationName === 'enable') {
|
||||
extensions = extensions.filter((ext) => !ext.isActive);
|
||||
}
|
||||
if (invocationName === 'disable') {
|
||||
extensions = extensions.filter((ext) => ext.isActive);
|
||||
}
|
||||
names = extensions.map((ext) => ext.name);
|
||||
} else if (name) {
|
||||
names = [name];
|
||||
} else {
|
||||
return { error: 'No extension name provided.' };
|
||||
}
|
||||
|
||||
return { extensionManager, names, scope };
|
||||
}
|
||||
|
||||
export class EnableExtensionCommand implements Command {
|
||||
readonly name = 'extensions enable';
|
||||
readonly description = 'Enable an extension.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
args: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const enableContext = getEnableDisableContext(
|
||||
context.config,
|
||||
args,
|
||||
'enable',
|
||||
);
|
||||
if ('error' in enableContext) {
|
||||
return { name: this.name, data: enableContext.error };
|
||||
}
|
||||
|
||||
const { names, scope, extensionManager } = enableContext;
|
||||
const output: string[] = [];
|
||||
|
||||
for (const name of names) {
|
||||
try {
|
||||
await extensionManager.enableExtension(name, scope);
|
||||
output.push(`Extension "${name}" enabled for scope "${scope}".`);
|
||||
|
||||
const extension = extensionManager
|
||||
.getExtensions()
|
||||
.find((e) => e.name === name);
|
||||
|
||||
if (extension?.mcpServers) {
|
||||
const mcpEnablementManager = McpServerEnablementManager.getInstance();
|
||||
const mcpClientManager = context.config.getMcpClientManager();
|
||||
const enabledServers = await mcpEnablementManager.autoEnableServers(
|
||||
Object.keys(extension.mcpServers),
|
||||
);
|
||||
|
||||
if (mcpClientManager && enabledServers.length > 0) {
|
||||
const restartPromises = enabledServers.map((serverName) =>
|
||||
mcpClientManager.restartServer(serverName).catch((error) => {
|
||||
output.push(
|
||||
`Failed to restart MCP server '${serverName}': ${getErrorMessage(error)}`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
await Promise.all(restartPromises);
|
||||
output.push(`Re-enabled MCP servers: ${enabledServers.join(', ')}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
output.push(`Failed to enable "${name}": ${getErrorMessage(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { name: this.name, data: output.join('\n') || 'No action taken.' };
|
||||
}
|
||||
}
|
||||
|
||||
export class DisableExtensionCommand implements Command {
|
||||
readonly name = 'extensions disable';
|
||||
readonly description = 'Disable an extension.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
args: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const enableContext = getEnableDisableContext(
|
||||
context.config,
|
||||
args,
|
||||
'disable',
|
||||
);
|
||||
if ('error' in enableContext) {
|
||||
return { name: this.name, data: enableContext.error };
|
||||
}
|
||||
|
||||
const { names, scope, extensionManager } = enableContext;
|
||||
const output: string[] = [];
|
||||
|
||||
for (const name of names) {
|
||||
try {
|
||||
await extensionManager.disableExtension(name, scope);
|
||||
output.push(`Extension "${name}" disabled for scope "${scope}".`);
|
||||
} catch (e) {
|
||||
output.push(`Failed to disable "${name}": ${getErrorMessage(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { name: this.name, data: output.join('\n') || 'No action taken.' };
|
||||
}
|
||||
}
|
||||
|
||||
export class InstallExtensionCommand implements Command {
|
||||
readonly name = 'extensions install';
|
||||
readonly description = 'Install an extension from a git repo or local path.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
args: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const extensionLoader = context.config.getExtensionLoader();
|
||||
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'Cannot install extensions in this environment.',
|
||||
};
|
||||
}
|
||||
|
||||
const source = args.join(' ').trim();
|
||||
if (!source) {
|
||||
return { name: this.name, data: `Usage: /extensions install <source>` };
|
||||
}
|
||||
|
||||
if (/[;&|`'"]/.test(source)) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Invalid source: contains disallowed characters.`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const installMetadata = await inferInstallMetadata(source);
|
||||
const extension =
|
||||
await extensionLoader.installOrUpdateExtension(installMetadata);
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Extension "${extension.name}" installed successfully.`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Failed to install extension from "${source}": ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class LinkExtensionCommand implements Command {
|
||||
readonly name = 'extensions link';
|
||||
readonly description = 'Link an extension from a local path.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
args: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const extensionLoader = context.config.getExtensionLoader();
|
||||
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'Cannot link extensions in this environment.',
|
||||
};
|
||||
}
|
||||
|
||||
const sourceFilepath = args.join(' ').trim();
|
||||
if (!sourceFilepath) {
|
||||
return { name: this.name, data: `Usage: /extensions link <source>` };
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(sourceFilepath);
|
||||
} catch (_error) {
|
||||
return { name: this.name, data: `Invalid source: ${sourceFilepath}` };
|
||||
}
|
||||
|
||||
try {
|
||||
const extension = await extensionLoader.installOrUpdateExtension({
|
||||
source: sourceFilepath,
|
||||
type: 'link',
|
||||
});
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Extension "${extension.name}" linked successfully.`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Failed to link extension: ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class UninstallExtensionCommand implements Command {
|
||||
readonly name = 'extensions uninstall';
|
||||
readonly description = 'Uninstall an extension.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
args: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const extensionLoader = context.config.getExtensionLoader();
|
||||
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'Cannot uninstall extensions in this environment.',
|
||||
};
|
||||
}
|
||||
|
||||
const name = args.join(' ').trim();
|
||||
if (!name) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Usage: /extensions uninstall <extension-name>`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await extensionLoader.uninstallExtension(name, false);
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Extension "${name}" uninstalled successfully.`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Failed to uninstall extension "${name}": ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RestartExtensionCommand implements Command {
|
||||
readonly name = 'extensions restart';
|
||||
readonly description = 'Restart an extension.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
args: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const extensionLoader = context.config.getExtensionLoader();
|
||||
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||
return { name: this.name, data: 'Cannot restart extensions.' };
|
||||
}
|
||||
|
||||
const all = args.includes('--all');
|
||||
const names = all ? null : args.filter((a) => !!a);
|
||||
|
||||
if (!all && names?.length === 0) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'Usage: /extensions restart <extension-names>|--all',
|
||||
};
|
||||
}
|
||||
|
||||
let extensionsToRestart = extensionLoader
|
||||
.getExtensions()
|
||||
.filter((e) => e.isActive);
|
||||
if (names) {
|
||||
extensionsToRestart = extensionsToRestart.filter((e) =>
|
||||
names.includes(e.name),
|
||||
);
|
||||
}
|
||||
|
||||
if (extensionsToRestart.length === 0) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'No active extensions matched the request.',
|
||||
};
|
||||
}
|
||||
|
||||
const output: string[] = [];
|
||||
for (const extension of extensionsToRestart) {
|
||||
try {
|
||||
await extensionLoader.restartExtension(extension);
|
||||
output.push(`Restarted "${extension.name}".`);
|
||||
} catch (e) {
|
||||
output.push(
|
||||
`Failed to restart "${extension.name}": ${getErrorMessage(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { name: this.name, data: output.join('\n') };
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateExtensionCommand implements Command {
|
||||
readonly name = 'extensions update';
|
||||
readonly description = 'Update an extension.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
args: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const extensionLoader = context.config.getExtensionLoader();
|
||||
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||
return { name: this.name, data: 'Cannot update extensions.' };
|
||||
}
|
||||
|
||||
const all = args.includes('--all');
|
||||
const names = all ? null : args.filter((a) => !!a);
|
||||
|
||||
if (!all && names?.length === 0) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'Usage: /extensions update <extension-names>|--all',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'Headless extension updating requires internal UI dispatches. Please use `gemini extensions update` directly in the terminal.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { performInit } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandExecutionResponse,
|
||||
} from './types.js';
|
||||
|
||||
export class InitCommand implements Command {
|
||||
name = 'init';
|
||||
description = 'Analyzes the project and creates a tailored GEMINI.md file';
|
||||
requiresWorkspace = true;
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
_args: string[] = [],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const targetDir = context.config.getTargetDir();
|
||||
if (!targetDir) {
|
||||
throw new Error('Command requires a workspace.');
|
||||
}
|
||||
|
||||
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
|
||||
const result = performInit(fs.existsSync(geminiMdPath));
|
||||
|
||||
switch (result.type) {
|
||||
case 'message':
|
||||
return {
|
||||
name: this.name,
|
||||
data: result,
|
||||
};
|
||||
case 'submit_prompt':
|
||||
fs.writeFileSync(geminiMdPath, '', 'utf8');
|
||||
|
||||
if (typeof result.content !== 'string') {
|
||||
throw new Error('Init command content must be a string.');
|
||||
}
|
||||
|
||||
// Inform the user since we can't trigger the UI-based interactive agent loop here directly.
|
||||
// We output the prompt text they can use to re-trigger the generation manually,
|
||||
// or just seed the GEMINI.md file as we've done above.
|
||||
return {
|
||||
name: this.name,
|
||||
data: {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `A template GEMINI.md has been created at ${geminiMdPath}.\n\nTo populate it with project context, you can run the following prompt in a new chat:\n\n${result.content}`,
|
||||
},
|
||||
};
|
||||
|
||||
default:
|
||||
throw new Error('Unknown result type from performInit');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
addMemory,
|
||||
listMemoryFiles,
|
||||
refreshMemory,
|
||||
showMemory,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandExecutionResponse,
|
||||
} from './types.js';
|
||||
|
||||
const DEFAULT_SANITIZATION_CONFIG = {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
};
|
||||
|
||||
export class MemoryCommand implements Command {
|
||||
readonly name = 'memory';
|
||||
readonly description = 'Manage memory.';
|
||||
readonly subCommands = [
|
||||
new ShowMemoryCommand(),
|
||||
new RefreshMemoryCommand(),
|
||||
new ListMemoryCommand(),
|
||||
new AddMemoryCommand(),
|
||||
];
|
||||
readonly requiresWorkspace = true;
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
return new ShowMemoryCommand().execute(context, _);
|
||||
}
|
||||
}
|
||||
|
||||
export class ShowMemoryCommand implements Command {
|
||||
readonly name = 'memory show';
|
||||
readonly description = 'Shows the current memory contents.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const result = showMemory(context.config);
|
||||
return { name: this.name, data: result.content };
|
||||
}
|
||||
}
|
||||
|
||||
export class RefreshMemoryCommand implements Command {
|
||||
readonly name = 'memory refresh';
|
||||
readonly aliases = ['memory reload'];
|
||||
readonly description = 'Refreshes the memory from the source.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const result = await refreshMemory(context.config);
|
||||
return { name: this.name, data: result.content };
|
||||
}
|
||||
}
|
||||
|
||||
export class ListMemoryCommand implements Command {
|
||||
readonly name = 'memory list';
|
||||
readonly description = 'Lists the paths of the GEMINI.md files in use.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const result = listMemoryFiles(context.config);
|
||||
return { name: this.name, data: result.content };
|
||||
}
|
||||
}
|
||||
|
||||
export class AddMemoryCommand implements Command {
|
||||
readonly name = 'memory add';
|
||||
readonly description = 'Add content to the memory.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
args: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const textToAdd = args.join(' ').trim();
|
||||
const result = addMemory(textToAdd);
|
||||
if (result.type === 'message') {
|
||||
return { name: this.name, data: result.content };
|
||||
}
|
||||
|
||||
const toolRegistry = context.config.getToolRegistry();
|
||||
const tool = toolRegistry.getTool(result.toolName);
|
||||
if (tool) {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
await context.sendMessage(`Saving memory via ${result.toolName}...`);
|
||||
|
||||
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
|
||||
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
|
||||
});
|
||||
await refreshMemory(context.config);
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Added memory: "${textToAdd}"`,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Error: Tool ${result.toolName} not found.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
getCheckpointInfoList,
|
||||
getToolCallDataSchema,
|
||||
isNodeError,
|
||||
performRestore,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandExecutionResponse,
|
||||
} from './types.js';
|
||||
|
||||
export class RestoreCommand implements Command {
|
||||
readonly name = 'restore';
|
||||
readonly description =
|
||||
'Restore to a previous checkpoint, or list available checkpoints to restore. This will reset the conversation and file history to the state it was in when the checkpoint was created';
|
||||
readonly requiresWorkspace = true;
|
||||
readonly subCommands = [new ListCheckpointsCommand()];
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
args: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const { config, git: gitService } = context;
|
||||
const argsStr = args.join(' ');
|
||||
|
||||
try {
|
||||
if (!argsStr) {
|
||||
return await new ListCheckpointsCommand().execute(context);
|
||||
}
|
||||
|
||||
if (!config.getCheckpointingEnabled()) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
|
||||
};
|
||||
}
|
||||
|
||||
const selectedFile = argsStr.endsWith('.json')
|
||||
? argsStr
|
||||
: `${argsStr}.json`;
|
||||
|
||||
const checkpointDir = config.storage.getProjectTempCheckpointsDir();
|
||||
const filePath = path.join(checkpointDir, selectedFile);
|
||||
|
||||
let data: string;
|
||||
try {
|
||||
data = await fs.readFile(filePath, 'utf-8');
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return {
|
||||
name: this.name,
|
||||
data: `File not found: ${selectedFile}`,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const toolCallData = JSON.parse(data);
|
||||
const ToolCallDataSchema = getToolCallDataSchema();
|
||||
const parseResult = ToolCallDataSchema.safeParse(toolCallData);
|
||||
|
||||
if (!parseResult.success) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'Checkpoint file is invalid or corrupted.',
|
||||
};
|
||||
}
|
||||
|
||||
const restoreResultGenerator = performRestore(
|
||||
parseResult.data,
|
||||
gitService,
|
||||
);
|
||||
|
||||
const restoreResult = [];
|
||||
for await (const result of restoreResultGenerator) {
|
||||
restoreResult.push(result);
|
||||
}
|
||||
|
||||
// Format the result nicely since Zed just dumps data
|
||||
const formattedResult = restoreResult
|
||||
.map((r) => {
|
||||
if (r.type === 'message') {
|
||||
return `[${r.messageType.toUpperCase()}] ${r.content}`;
|
||||
} else if (r.type === 'load_history') {
|
||||
return `Loaded history with ${r.clientHistory.length} messages.`;
|
||||
}
|
||||
return `Restored: ${JSON.stringify(r)}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
name: this.name,
|
||||
data: formattedResult,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: `An unexpected error occurred during restore: ${error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ListCheckpointsCommand implements Command {
|
||||
readonly name = 'restore list';
|
||||
readonly description = 'Lists all available checkpoints.';
|
||||
|
||||
async execute(context: CommandContext): Promise<CommandExecutionResponse> {
|
||||
const { config } = context;
|
||||
|
||||
try {
|
||||
if (!config.getCheckpointingEnabled()) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
|
||||
};
|
||||
}
|
||||
|
||||
const checkpointDir = config.storage.getProjectTempCheckpointsDir();
|
||||
try {
|
||||
await fs.mkdir(checkpointDir, { recursive: true });
|
||||
} catch (_e) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
const files = await fs.readdir(checkpointDir);
|
||||
const jsonFiles = files.filter((file) => file.endsWith('.json'));
|
||||
|
||||
if (jsonFiles.length === 0) {
|
||||
return { name: this.name, data: 'No checkpoints found.' };
|
||||
}
|
||||
|
||||
const checkpointFiles = new Map<string, string>();
|
||||
for (const file of jsonFiles) {
|
||||
const filePath = path.join(checkpointDir, file);
|
||||
const data = await fs.readFile(filePath, 'utf-8');
|
||||
checkpointFiles.set(file, data);
|
||||
}
|
||||
|
||||
const checkpointInfoList = getCheckpointInfoList(checkpointFiles);
|
||||
|
||||
const formatted = checkpointInfoList
|
||||
.map((info) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const i = info as Record<string, any>;
|
||||
const fileName = String(i['fileName'] || 'Unknown');
|
||||
const toolName = String(i['toolName'] || 'Unknown');
|
||||
const status = String(i['status'] || 'Unknown');
|
||||
const timestamp = new Date(
|
||||
Number(i['timestamp']) || 0,
|
||||
).toLocaleString();
|
||||
|
||||
return `- **${fileName}**: ${toolName} (Status: ${status}) [${timestamp}]`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Available Checkpoints:\n${formatted}`,
|
||||
};
|
||||
} catch (_error) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'An unexpected error occurred while listing checkpoints.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config, GitService } from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
export interface CommandContext {
|
||||
config: Config;
|
||||
settings: LoadedSettings;
|
||||
git?: GitService;
|
||||
sendMessage: (text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface CommandArgument {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly isRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
readonly name: string;
|
||||
readonly aliases?: string[];
|
||||
readonly description: string;
|
||||
readonly arguments?: CommandArgument[];
|
||||
readonly subCommands?: Command[];
|
||||
readonly requiresWorkspace?: boolean;
|
||||
|
||||
execute(
|
||||
context: CommandContext,
|
||||
args: string[],
|
||||
): Promise<CommandExecutionResponse>;
|
||||
}
|
||||
|
||||
export interface CommandExecutionResponse {
|
||||
readonly name: string;
|
||||
readonly data: unknown;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type Mocked,
|
||||
} from 'vitest';
|
||||
import { GeminiAgent, Session } from './zedIntegration.js';
|
||||
import type { CommandHandler } from './commandHandler.js';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import {
|
||||
AuthType,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
type Config,
|
||||
type MessageBus,
|
||||
LlmRole,
|
||||
type GitService,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
SettingScope,
|
||||
@@ -62,7 +64,33 @@ vi.mock('node:path', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock ReadManyFilesTool
|
||||
vi.mock('../ui/commands/memoryCommand.js', () => ({
|
||||
memoryCommand: {
|
||||
name: 'memory',
|
||||
action: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../ui/commands/extensionsCommand.js', () => ({
|
||||
extensionsCommand: vi.fn().mockReturnValue({
|
||||
name: 'extensions',
|
||||
action: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../ui/commands/restoreCommand.js', () => ({
|
||||
restoreCommand: vi.fn().mockReturnValue({
|
||||
name: 'restore',
|
||||
action: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../ui/commands/initCommand.js', () => ({
|
||||
initCommand: {
|
||||
name: 'init',
|
||||
action: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock(
|
||||
'@google/gemini-cli-core',
|
||||
async (
|
||||
@@ -145,6 +173,9 @@ describe('GeminiAgent', () => {
|
||||
}),
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(false),
|
||||
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Mocked<Awaited<ReturnType<typeof loadCliConfig>>>;
|
||||
mockSettings = {
|
||||
merged: {
|
||||
@@ -225,6 +256,7 @@ describe('GeminiAgent', () => {
|
||||
});
|
||||
|
||||
it('should create a new session', async () => {
|
||||
vi.useFakeTimers();
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
@@ -237,6 +269,17 @@ describe('GeminiAgent', () => {
|
||||
expect(loadCliConfig).toHaveBeenCalled();
|
||||
expect(mockConfig.initialize).toHaveBeenCalled();
|
||||
expect(mockConfig.getGeminiClient).toHaveBeenCalled();
|
||||
|
||||
// Verify deferred call
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'available_commands_update',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return modes without plan mode when plan is disabled', async () => {
|
||||
@@ -263,6 +306,38 @@ describe('GeminiAgent', () => {
|
||||
],
|
||||
currentModeId: 'default',
|
||||
});
|
||||
expect(response.models).toEqual({
|
||||
availableModels: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
modelId: 'auto-gemini-2.5',
|
||||
name: 'Auto (Gemini 2.5)',
|
||||
}),
|
||||
]),
|
||||
currentModelId: 'gemini-pro',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include preview models when user has access', async () => {
|
||||
mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
|
||||
|
||||
const response = await agent.newSession({
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(response.models?.availableModels).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
modelId: 'auto-gemini-3',
|
||||
name: expect.stringContaining('Auto'),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
modelId: 'gemini-3.1-pro-preview',
|
||||
name: 'gemini-3.1-pro-preview',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return modes with plan mode when plan is enabled', async () => {
|
||||
@@ -290,6 +365,15 @@ describe('GeminiAgent', () => {
|
||||
],
|
||||
currentModeId: 'plan',
|
||||
});
|
||||
expect(response.models).toEqual({
|
||||
availableModels: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
modelId: 'auto-gemini-2.5',
|
||||
name: 'Auto (Gemini 2.5)',
|
||||
}),
|
||||
]),
|
||||
currentModelId: 'gemini-pro',
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail session creation if Gemini API key is missing', async () => {
|
||||
@@ -439,6 +523,32 @@ describe('GeminiAgent', () => {
|
||||
}),
|
||||
).rejects.toThrow('Session not found: unknown');
|
||||
});
|
||||
|
||||
it('should delegate setModel to session (unstable)', async () => {
|
||||
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
|
||||
const session = (
|
||||
agent as unknown as { sessions: Map<string, Session> }
|
||||
).sessions.get('test-session-id');
|
||||
if (!session) throw new Error('Session not found');
|
||||
session.setModel = vi.fn().mockReturnValue({});
|
||||
|
||||
const result = await agent.unstable_setSessionModel({
|
||||
sessionId: 'test-session-id',
|
||||
modelId: 'gemini-2.0-pro-exp',
|
||||
});
|
||||
|
||||
expect(session.setModel).toHaveBeenCalledWith('gemini-2.0-pro-exp');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should throw error when setting model on non-existent session (unstable)', async () => {
|
||||
await expect(
|
||||
agent.unstable_setSessionModel({
|
||||
sessionId: 'unknown',
|
||||
modelId: 'gemini-2.0-pro-exp',
|
||||
}),
|
||||
).rejects.toThrow('Session not found: unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session', () => {
|
||||
@@ -477,6 +587,7 @@ describe('Session', () => {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getMcpServers: vi.fn(),
|
||||
getFileService: vi.fn().mockReturnValue({
|
||||
shouldIgnoreFile: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
@@ -486,7 +597,10 @@ describe('Session', () => {
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
|
||||
setApprovalMode: vi.fn(),
|
||||
setModel: vi.fn(),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(false),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
getGitService: vi.fn().mockResolvedValue({} as GitService),
|
||||
waitForMcpInit: vi.fn(),
|
||||
} as unknown as Mocked<Config>;
|
||||
mockConnection = {
|
||||
@@ -495,13 +609,38 @@ describe('Session', () => {
|
||||
sendNotification: vi.fn(),
|
||||
} as unknown as Mocked<acp.AgentSideConnection>;
|
||||
|
||||
session = new Session('session-1', mockChat, mockConfig, mockConnection);
|
||||
session = new Session('session-1', mockChat, mockConfig, mockConnection, {
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
merged: { settings: {} },
|
||||
errors: [],
|
||||
} as unknown as LoadedSettings);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should send available commands', async () => {
|
||||
await session.sendAvailableCommands();
|
||||
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'memory' }),
|
||||
expect.objectContaining({ name: 'extensions' }),
|
||||
expect.objectContaining({ name: 'restore' }),
|
||||
expect.objectContaining({ name: 'init' }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should await MCP initialization before processing a prompt', async () => {
|
||||
const stream = createMockStream([
|
||||
{
|
||||
@@ -551,6 +690,113 @@ describe('Session', () => {
|
||||
expect(result).toEqual({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle /memory command', async () => {
|
||||
const handleCommandSpy = vi
|
||||
.spyOn(
|
||||
(session as unknown as { commandHandler: CommandHandler })
|
||||
.commandHandler,
|
||||
'handleCommand',
|
||||
)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: '/memory view' }],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ stopReason: 'end_turn' });
|
||||
expect(handleCommandSpy).toHaveBeenCalledWith(
|
||||
'/memory view',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle /extensions command', async () => {
|
||||
const handleCommandSpy = vi
|
||||
.spyOn(
|
||||
(session as unknown as { commandHandler: CommandHandler })
|
||||
.commandHandler,
|
||||
'handleCommand',
|
||||
)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: '/extensions list' }],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ stopReason: 'end_turn' });
|
||||
expect(handleCommandSpy).toHaveBeenCalledWith(
|
||||
'/extensions list',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle /extensions explore command', async () => {
|
||||
const handleCommandSpy = vi
|
||||
.spyOn(
|
||||
(session as unknown as { commandHandler: CommandHandler })
|
||||
.commandHandler,
|
||||
'handleCommand',
|
||||
)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: '/extensions explore' }],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ stopReason: 'end_turn' });
|
||||
expect(handleCommandSpy).toHaveBeenCalledWith(
|
||||
'/extensions explore',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle /restore command', async () => {
|
||||
const handleCommandSpy = vi
|
||||
.spyOn(
|
||||
(session as unknown as { commandHandler: CommandHandler })
|
||||
.commandHandler,
|
||||
'handleCommand',
|
||||
)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: '/restore' }],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ stopReason: 'end_turn' });
|
||||
expect(handleCommandSpy).toHaveBeenCalledWith(
|
||||
'/restore',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle /init command', async () => {
|
||||
const handleCommandSpy = vi
|
||||
.spyOn(
|
||||
(session as unknown as { commandHandler: CommandHandler })
|
||||
.commandHandler,
|
||||
'handleCommand',
|
||||
)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: '/init' }],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ stopReason: 'end_turn' });
|
||||
expect(handleCommandSpy).toHaveBeenCalledWith('/init', expect.any(Object));
|
||||
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle tool calls', async () => {
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
@@ -1207,4 +1453,30 @@ describe('Session', () => {
|
||||
'Invalid or unavailable mode: invalid-mode',
|
||||
);
|
||||
});
|
||||
|
||||
it('should set model on config', () => {
|
||||
session.setModel('gemini-2.0-flash-exp');
|
||||
expect(mockConfig.setModel).toHaveBeenCalledWith('gemini-2.0-flash-exp');
|
||||
});
|
||||
|
||||
it('should handle unquoted commands from autocomplete (with empty leading parts)', async () => {
|
||||
// Mock handleCommand to verify it gets called
|
||||
const handleCommandSpy = vi
|
||||
.spyOn(
|
||||
(session as unknown as { commandHandler: CommandHandler })
|
||||
.commandHandler,
|
||||
'handleCommand',
|
||||
)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [
|
||||
{ type: 'text', text: '' },
|
||||
{ type: 'text', text: '/memory' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(handleCommandSpy).toHaveBeenCalledWith('/memory', expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,15 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
Config,
|
||||
GeminiChat,
|
||||
ToolResult,
|
||||
ToolCallConfirmationDetails,
|
||||
FilterFilesOptions,
|
||||
ConversationRecord,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type Config,
|
||||
type GeminiChat,
|
||||
type ToolResult,
|
||||
type ToolCallConfirmationDetails,
|
||||
type FilterFilesOptions,
|
||||
type ConversationRecord,
|
||||
CoreToolCallStatus,
|
||||
AuthType,
|
||||
logToolCall,
|
||||
@@ -39,6 +37,16 @@ import {
|
||||
ApprovalMode,
|
||||
getVersion,
|
||||
convertSessionToClientHistory,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
getDisplayString,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { AcpFileSystemService } from './fileSystemService.js';
|
||||
@@ -61,11 +69,14 @@ import { loadCliConfig } from '../config/config.js';
|
||||
import { runExitCleanup } from '../utils/cleanup.js';
|
||||
import { SessionSelector } from '../utils/sessionUtils.js';
|
||||
|
||||
import { CommandHandler } from './commandHandler.js';
|
||||
export async function runZedIntegration(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
argv: CliArgs,
|
||||
) {
|
||||
// ... (skip unchanged lines) ...
|
||||
|
||||
const { stdout: workingStdout } = createWorkingStdio();
|
||||
const stdout = Writable.toWeb(workingStdout) as WritableStream;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
@@ -240,16 +251,37 @@ export class GeminiAgent {
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
const chat = await geminiClient.startChat();
|
||||
const session = new Session(sessionId, chat, config, this.connection);
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
chat,
|
||||
config,
|
||||
this.connection,
|
||||
this.settings,
|
||||
);
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
return {
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.sendAvailableCommands();
|
||||
}, 0);
|
||||
|
||||
const { availableModels, currentModelId } = buildAvailableModels(
|
||||
config,
|
||||
loadedSettings,
|
||||
);
|
||||
|
||||
const response = {
|
||||
sessionId,
|
||||
modes: {
|
||||
availableModes: buildAvailableModes(config.isPlanEnabled()),
|
||||
currentModeId: config.getApprovalMode(),
|
||||
},
|
||||
models: {
|
||||
availableModels,
|
||||
currentModelId,
|
||||
},
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
async loadSession({
|
||||
@@ -291,6 +323,7 @@ export class GeminiAgent {
|
||||
geminiClient.getChat(),
|
||||
config,
|
||||
this.connection,
|
||||
this.settings,
|
||||
);
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
@@ -298,12 +331,27 @@ export class GeminiAgent {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.streamHistory(sessionData.messages);
|
||||
|
||||
return {
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.sendAvailableCommands();
|
||||
}, 0);
|
||||
|
||||
const { availableModels, currentModelId } = buildAvailableModels(
|
||||
config,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
const response = {
|
||||
modes: {
|
||||
availableModes: buildAvailableModes(config.isPlanEnabled()),
|
||||
currentModeId: config.getApprovalMode(),
|
||||
},
|
||||
models: {
|
||||
availableModels,
|
||||
currentModelId,
|
||||
},
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
private async initializeSessionConfig(
|
||||
@@ -414,16 +462,28 @@ export class GeminiAgent {
|
||||
}
|
||||
return session.setMode(params.modeId);
|
||||
}
|
||||
|
||||
async unstable_setSessionModel(
|
||||
params: acp.SetSessionModelRequest,
|
||||
): Promise<acp.SetSessionModelResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`);
|
||||
}
|
||||
return session.setModel(params.modelId);
|
||||
}
|
||||
}
|
||||
|
||||
export class Session {
|
||||
private pendingPrompt: AbortController | null = null;
|
||||
private commandHandler = new CommandHandler();
|
||||
|
||||
constructor(
|
||||
private readonly id: string,
|
||||
private readonly chat: GeminiChat,
|
||||
private readonly config: Config,
|
||||
private readonly connection: acp.AgentSideConnection,
|
||||
private readonly settings: LoadedSettings,
|
||||
) {}
|
||||
|
||||
async cancelPendingPrompt(): Promise<void> {
|
||||
@@ -446,6 +506,27 @@ export class Session {
|
||||
return {};
|
||||
}
|
||||
|
||||
private getAvailableCommands() {
|
||||
return this.commandHandler.getAvailableCommands();
|
||||
}
|
||||
|
||||
async sendAvailableCommands(): Promise<void> {
|
||||
const availableCommands = this.getAvailableCommands().map((command) => ({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
}));
|
||||
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands,
|
||||
});
|
||||
}
|
||||
|
||||
setModel(modelId: acp.ModelId): acp.SetSessionModelResponse {
|
||||
this.config.setModel(modelId);
|
||||
return {};
|
||||
}
|
||||
|
||||
async streamHistory(messages: ConversationRecord['messages']): Promise<void> {
|
||||
for (const msg of messages) {
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
@@ -528,6 +609,41 @@ export class Session {
|
||||
|
||||
const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
|
||||
|
||||
// Command interception
|
||||
let commandText = '';
|
||||
|
||||
for (const part of parts) {
|
||||
if (typeof part === 'object' && part !== null) {
|
||||
if ('text' in part) {
|
||||
// It is a text part
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-type-assertion
|
||||
const text = (part as any).text;
|
||||
if (typeof text === 'string') {
|
||||
commandText += text;
|
||||
}
|
||||
} else {
|
||||
// Non-text part (image, embedded resource)
|
||||
// Stop looking for command
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
commandText = commandText.trim();
|
||||
|
||||
if (
|
||||
commandText &&
|
||||
(commandText.startsWith('/') || commandText.startsWith('$'))
|
||||
) {
|
||||
// If we found a command, pass it to handleCommand
|
||||
// Note: handleCommand currently expects `commandText` to be the command string
|
||||
// It uses `parts` argument but effectively ignores it in current implementation
|
||||
const handled = await this.handleCommand(commandText, parts);
|
||||
if (handled) {
|
||||
return { stopReason: 'end_turn' };
|
||||
}
|
||||
}
|
||||
|
||||
let nextMessage: Content | null = { role: 'user', parts };
|
||||
|
||||
while (nextMessage !== null) {
|
||||
@@ -627,9 +743,28 @@ export class Session {
|
||||
return { stopReason: 'end_turn' };
|
||||
}
|
||||
|
||||
private async sendUpdate(
|
||||
update: acp.SessionNotification['update'],
|
||||
): Promise<void> {
|
||||
private async handleCommand(
|
||||
commandText: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
parts: Part[],
|
||||
): Promise<boolean> {
|
||||
const gitService = await this.config.getGitService();
|
||||
const commandContext = {
|
||||
config: this.config,
|
||||
settings: this.settings,
|
||||
git: gitService,
|
||||
sendMessage: async (text: string) => {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return this.commandHandler.handleCommand(commandText, commandContext);
|
||||
}
|
||||
|
||||
private async sendUpdate(update: acp.SessionUpdate): Promise<void> {
|
||||
const params: acp.SessionNotification = {
|
||||
sessionId: this.id,
|
||||
update,
|
||||
@@ -1377,3 +1512,94 @@ function buildAvailableModes(isPlanEnabled: boolean): acp.SessionMode[] {
|
||||
|
||||
return modes;
|
||||
}
|
||||
|
||||
function buildAvailableModels(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
): {
|
||||
availableModels: Array<{
|
||||
modelId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}>;
|
||||
currentModelId: string;
|
||||
} {
|
||||
const preferredModel = config.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
|
||||
const shouldShowPreviewModels = config.getHasAccessToPreviewModel();
|
||||
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
|
||||
const selectedAuthType = settings.merged.security.auth.selectedType;
|
||||
const useCustomToolModel =
|
||||
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
|
||||
|
||||
const mainOptions = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
title: getDisplayString(DEFAULT_GEMINI_MODEL_AUTO),
|
||||
description:
|
||||
'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldShowPreviewModels) {
|
||||
mainOptions.unshift({
|
||||
value: PREVIEW_GEMINI_MODEL_AUTO,
|
||||
title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO),
|
||||
description: useGemini31
|
||||
? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash'
|
||||
: 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
|
||||
});
|
||||
}
|
||||
|
||||
const manualOptions = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_MODEL),
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL),
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldShowPreviewModels) {
|
||||
const previewProModel = useGemini31
|
||||
? PREVIEW_GEMINI_3_1_MODEL
|
||||
: PREVIEW_GEMINI_MODEL;
|
||||
|
||||
const previewProValue = useCustomToolModel
|
||||
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
|
||||
: previewProModel;
|
||||
|
||||
manualOptions.unshift(
|
||||
{
|
||||
value: previewProValue,
|
||||
title: getDisplayString(previewProModel),
|
||||
},
|
||||
{
|
||||
value: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const scaleOptions = (
|
||||
options: Array<{ value: string; title: string; description?: string }>,
|
||||
) =>
|
||||
options.map((o) => ({
|
||||
modelId: o.value,
|
||||
name: o.title,
|
||||
description: o.description,
|
||||
}));
|
||||
|
||||
return {
|
||||
availableModels: [
|
||||
...scaleOptions(mainOptions),
|
||||
...scaleOptions(manualOptions),
|
||||
],
|
||||
currentModelId: preferredModel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.33.0-nightly.20260228.1ca5c05d0",
|
||||
"version": "0.33.0-preview.2",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
extractIdsFromResponse,
|
||||
isTerminalState,
|
||||
A2AResultReassembler,
|
||||
AUTH_REQUIRED_MSG,
|
||||
} from './a2aUtils.js';
|
||||
import type { SendMessageResult } from './a2a-client-manager.js';
|
||||
import type {
|
||||
@@ -285,6 +286,66 @@ describe('a2aUtils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle auth-required state with a message', () => {
|
||||
const reassembler = new A2AResultReassembler();
|
||||
|
||||
reassembler.update({
|
||||
kind: 'status-update',
|
||||
status: {
|
||||
state: 'auth-required',
|
||||
message: {
|
||||
kind: 'message',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'I need your permission.' }],
|
||||
} as Message,
|
||||
},
|
||||
} as unknown as SendMessageResult);
|
||||
|
||||
expect(reassembler.toString()).toContain('I need your permission.');
|
||||
expect(reassembler.toString()).toContain(AUTH_REQUIRED_MSG);
|
||||
});
|
||||
|
||||
it('should handle auth-required state without relying on metadata', () => {
|
||||
const reassembler = new A2AResultReassembler();
|
||||
|
||||
reassembler.update({
|
||||
kind: 'status-update',
|
||||
status: {
|
||||
state: 'auth-required',
|
||||
},
|
||||
} as unknown as SendMessageResult);
|
||||
|
||||
expect(reassembler.toString()).toContain(AUTH_REQUIRED_MSG);
|
||||
});
|
||||
|
||||
it('should not duplicate the auth instruction OR agent message if multiple identical auth-required chunks arrive', () => {
|
||||
const reassembler = new A2AResultReassembler();
|
||||
|
||||
const chunk = {
|
||||
kind: 'status-update',
|
||||
status: {
|
||||
state: 'auth-required',
|
||||
message: {
|
||||
kind: 'message',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'You need to login here.' }],
|
||||
} as Message,
|
||||
},
|
||||
} as unknown as SendMessageResult;
|
||||
|
||||
reassembler.update(chunk);
|
||||
// Simulate multiple updates with the same overall state
|
||||
reassembler.update(chunk);
|
||||
reassembler.update(chunk);
|
||||
|
||||
const output = reassembler.toString();
|
||||
// The substring should only appear exactly once
|
||||
expect(output.split(AUTH_REQUIRED_MSG).length - 1).toBe(1);
|
||||
|
||||
// Crucially, the agent's actual custom message should ALSO only appear exactly once
|
||||
expect(output.split('You need to login here.').length - 1).toBe(1);
|
||||
});
|
||||
|
||||
it('should fallback to history in a task chunk if no message or artifacts exist and task is terminal', () => {
|
||||
const reassembler = new A2AResultReassembler();
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import type {
|
||||
} from '@a2a-js/sdk';
|
||||
import type { SendMessageResult } from './a2a-client-manager.js';
|
||||
|
||||
export const AUTH_REQUIRED_MSG = `[Authorization Required] The agent has indicated it requires authorization to proceed. Please follow the agent's instructions.`;
|
||||
|
||||
/**
|
||||
* Reassembles incremental A2A streaming updates into a coherent result.
|
||||
* Shows sequential status/messages followed by all reassembled artifacts.
|
||||
@@ -33,6 +35,7 @@ export class A2AResultReassembler {
|
||||
|
||||
switch (chunk.kind) {
|
||||
case 'status-update':
|
||||
this.appendStateInstructions(chunk.status?.state);
|
||||
this.pushMessage(chunk.status?.message);
|
||||
break;
|
||||
|
||||
@@ -65,6 +68,7 @@ export class A2AResultReassembler {
|
||||
break;
|
||||
|
||||
case 'task':
|
||||
this.appendStateInstructions(chunk.status?.state);
|
||||
this.pushMessage(chunk.status?.message);
|
||||
if (chunk.artifacts) {
|
||||
for (const art of chunk.artifacts) {
|
||||
@@ -106,6 +110,17 @@ export class A2AResultReassembler {
|
||||
}
|
||||
}
|
||||
|
||||
private appendStateInstructions(state: TaskState | undefined) {
|
||||
if (state !== 'auth-required') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent duplicate instructions if multiple chunks report auth-required
|
||||
if (!this.messageLog.includes(AUTH_REQUIRED_MSG)) {
|
||||
this.messageLog.push(AUTH_REQUIRED_MSG);
|
||||
}
|
||||
}
|
||||
|
||||
private pushMessage(message: Message | undefined) {
|
||||
if (!message) return;
|
||||
const text = extractPartsText(message.parts, '\n');
|
||||
|
||||
@@ -508,6 +508,16 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
}
|
||||
|
||||
interface VpcScErrorResponse {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: {
|
||||
details?: unknown[];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function isVpcScErrorResponse(error: unknown): error is VpcScErrorResponse & {
|
||||
response: {
|
||||
data: {
|
||||
error: {
|
||||
@@ -515,9 +525,7 @@ interface VpcScErrorResponse {
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function isVpcScErrorResponse(error: unknown): error is VpcScErrorResponse {
|
||||
} {
|
||||
return (
|
||||
!!error &&
|
||||
typeof error === 'object' &&
|
||||
|
||||
@@ -2950,9 +2950,11 @@ describe('Plans Directory Initialization', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(fs.promises.mkdir).mockRestore();
|
||||
vi.mocked(fs.promises.access).mockRestore?.();
|
||||
});
|
||||
|
||||
it('should create plans directory and add it to workspace context when plan is enabled', async () => {
|
||||
it('should add plans directory to workspace context if it exists', async () => {
|
||||
vi.spyOn(fs.promises, 'access').mockResolvedValue(undefined);
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: true,
|
||||
@@ -2961,14 +2963,32 @@ describe('Plans Directory Initialization', () => {
|
||||
await config.initialize();
|
||||
|
||||
const plansDir = config.storage.getPlansDir();
|
||||
expect(fs.promises.mkdir).toHaveBeenCalledWith(plansDir, {
|
||||
recursive: true,
|
||||
});
|
||||
// Should NOT create the directory eagerly
|
||||
expect(fs.promises.mkdir).not.toHaveBeenCalled();
|
||||
// Should check if it exists
|
||||
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);
|
||||
|
||||
const context = config.getWorkspaceContext();
|
||||
expect(context.getDirectories()).toContain(plansDir);
|
||||
});
|
||||
|
||||
it('should NOT add plans directory to workspace context if it does not exist', async () => {
|
||||
vi.spyOn(fs.promises, 'access').mockRejectedValue({ code: 'ENOENT' });
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: true,
|
||||
});
|
||||
|
||||
await config.initialize();
|
||||
|
||||
const plansDir = config.storage.getPlansDir();
|
||||
expect(fs.promises.mkdir).not.toHaveBeenCalled();
|
||||
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);
|
||||
|
||||
const context = config.getWorkspaceContext();
|
||||
expect(context.getDirectories()).not.toContain(plansDir);
|
||||
});
|
||||
|
||||
it('should NOT create plans directory or add it to workspace context when plan is disabled', async () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
|
||||
@@ -339,6 +339,15 @@ export interface GeminiCLIExtension {
|
||||
* Safety checkers contributed by this extension.
|
||||
*/
|
||||
checkers?: SafetyCheckerRule[];
|
||||
/**
|
||||
* Planning features configuration contributed by this extension.
|
||||
*/
|
||||
plan?: {
|
||||
/**
|
||||
* The directory where planning artifacts are stored.
|
||||
*/
|
||||
directory?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExtensionInstallMetadata {
|
||||
@@ -1093,8 +1102,15 @@ export class Config implements McpContext {
|
||||
// Add plans directory to workspace context for plan file storage
|
||||
if (this.planEnabled) {
|
||||
const plansDir = this.storage.getPlansDir();
|
||||
await fs.promises.mkdir(plansDir, { recursive: true });
|
||||
this.workspaceContext.addDirectory(plansDir);
|
||||
try {
|
||||
await fs.promises.access(plansDir);
|
||||
this.workspaceContext.addDirectory(plansDir);
|
||||
} catch {
|
||||
// Directory does not exist yet, so we don't add it to the workspace context.
|
||||
// It will be created when the first plan is written. Since custom plan
|
||||
// directories must be within the project root, they are automatically
|
||||
// covered by the project-wide file discovery once created.
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize centralized FileDiscoveryService
|
||||
|
||||
@@ -20,7 +20,10 @@ import { ToolErrorType } from '../tools/tool-error.js';
|
||||
import { ToolCallEvent } from '../telemetry/types.js';
|
||||
import { runInDevTraceSpan } from '../telemetry/trace.js';
|
||||
import { ToolModificationHandler } from '../scheduler/tool-modifier.js';
|
||||
import { getToolSuggestion } from '../utils/tool-utils.js';
|
||||
import {
|
||||
getToolSuggestion,
|
||||
isToolCallResponseInfo,
|
||||
} from '../utils/tool-utils.js';
|
||||
import type { ToolConfirmationRequest } from '../confirmation-bus/types.js';
|
||||
import { MessageBusType } from '../confirmation-bus/types.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
@@ -225,32 +228,36 @@ export class CoreToolScheduler {
|
||||
const durationMs = existingStartTime
|
||||
? Date.now() - existingStartTime
|
||||
: undefined;
|
||||
return {
|
||||
request: currentCall.request,
|
||||
tool: toolInstance,
|
||||
invocation,
|
||||
status: CoreToolCallStatus.Success,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
response: auxiliaryData as ToolCallResponseInfo,
|
||||
durationMs,
|
||||
outcome,
|
||||
approvalMode,
|
||||
} as SuccessfulToolCall;
|
||||
if (isToolCallResponseInfo(auxiliaryData)) {
|
||||
return {
|
||||
request: currentCall.request,
|
||||
tool: toolInstance,
|
||||
invocation,
|
||||
status: CoreToolCallStatus.Success,
|
||||
response: auxiliaryData,
|
||||
durationMs,
|
||||
outcome,
|
||||
approvalMode,
|
||||
} as SuccessfulToolCall;
|
||||
}
|
||||
throw new Error('Invalid response data for tool success');
|
||||
}
|
||||
case CoreToolCallStatus.Error: {
|
||||
const durationMs = existingStartTime
|
||||
? Date.now() - existingStartTime
|
||||
: undefined;
|
||||
return {
|
||||
request: currentCall.request,
|
||||
status: CoreToolCallStatus.Error,
|
||||
tool: toolInstance,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
response: auxiliaryData as ToolCallResponseInfo,
|
||||
durationMs,
|
||||
outcome,
|
||||
approvalMode,
|
||||
} as ErroredToolCall;
|
||||
if (isToolCallResponseInfo(auxiliaryData)) {
|
||||
return {
|
||||
request: currentCall.request,
|
||||
status: CoreToolCallStatus.Error,
|
||||
tool: toolInstance,
|
||||
response: auxiliaryData,
|
||||
durationMs,
|
||||
outcome,
|
||||
approvalMode,
|
||||
} as ErroredToolCall;
|
||||
}
|
||||
throw new Error('Invalid response data for tool error');
|
||||
}
|
||||
case CoreToolCallStatus.AwaitingApproval:
|
||||
return {
|
||||
@@ -280,6 +287,19 @@ export class CoreToolScheduler {
|
||||
? Date.now() - existingStartTime
|
||||
: undefined;
|
||||
|
||||
if (isToolCallResponseInfo(auxiliaryData)) {
|
||||
return {
|
||||
request: currentCall.request,
|
||||
tool: toolInstance,
|
||||
invocation,
|
||||
status: CoreToolCallStatus.Cancelled,
|
||||
response: auxiliaryData,
|
||||
durationMs,
|
||||
outcome,
|
||||
approvalMode,
|
||||
} as CancelledToolCall;
|
||||
}
|
||||
|
||||
// Preserve diff for cancelled edit operations
|
||||
let resultDisplay: ToolResultDisplay | undefined = undefined;
|
||||
if (currentCall.status === CoreToolCallStatus.AwaitingApproval) {
|
||||
|
||||
@@ -72,7 +72,16 @@ priority = 70
|
||||
modes = ["plan"]
|
||||
|
||||
[[rule]]
|
||||
toolName = ["glob", "grep_search", "list_directory", "read_file", "google_web_search", "activate_skill"]
|
||||
toolName = [
|
||||
"glob",
|
||||
"grep_search",
|
||||
"list_directory",
|
||||
"read_file",
|
||||
"google_web_search",
|
||||
"activate_skill",
|
||||
"codebase_investigator",
|
||||
"cli_help"
|
||||
]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
|
||||
@@ -51,3 +51,8 @@ priority = 50
|
||||
toolName = "google_web_search"
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
|
||||
[[rule]]
|
||||
toolName = ["codebase_investigator", "cli_help"]
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
@@ -1593,7 +1593,7 @@ describe('PolicyEngine', () => {
|
||||
modes: [ApprovalMode.PLAN],
|
||||
},
|
||||
{
|
||||
toolName: 'codebase_investigator',
|
||||
toolName: 'unknown_subagent',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: PRIORITY_SUBAGENT_TOOL,
|
||||
},
|
||||
@@ -1605,7 +1605,7 @@ describe('PolicyEngine', () => {
|
||||
});
|
||||
|
||||
const fixedResult = await fixedEngine.check(
|
||||
{ name: 'codebase_investigator' },
|
||||
{ name: 'unknown_subagent' },
|
||||
undefined,
|
||||
);
|
||||
|
||||
|
||||
@@ -909,7 +909,7 @@ priority = 100
|
||||
}
|
||||
});
|
||||
|
||||
it('should override default subagent rules when in Plan Mode', async () => {
|
||||
it('should override default subagent rules when in Plan Mode for unknown subagents', async () => {
|
||||
const planTomlPath = path.resolve(__dirname, 'policies', 'plan.toml');
|
||||
const fileContent = await fs.readFile(planTomlPath, 'utf-8');
|
||||
const tempPolicyDir = await fs.mkdtemp(
|
||||
@@ -931,9 +931,9 @@ priority = 100
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
});
|
||||
|
||||
// 3. Simulate a Subagent being registered (Dynamic Rule)
|
||||
// 3. Simulate an unknown Subagent being registered (Dynamic Rule)
|
||||
engine.addRule({
|
||||
toolName: 'codebase_investigator',
|
||||
toolName: 'unknown_subagent',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: PRIORITY_SUBAGENT_TOOL,
|
||||
source: 'AgentRegistry (Dynamic)',
|
||||
@@ -942,13 +942,13 @@ priority = 100
|
||||
// 4. Verify Behavior:
|
||||
// The Plan Mode "Catch-All Deny" (from plan.toml) should override the Subagent Allow
|
||||
const checkResult = await engine.check(
|
||||
{ name: 'codebase_investigator' },
|
||||
{ name: 'unknown_subagent' },
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(
|
||||
checkResult.decision,
|
||||
'Subagent should be DENIED in Plan Mode',
|
||||
'Unknown subagent should be DENIED in Plan Mode',
|
||||
).toBe(PolicyDecision.DENY);
|
||||
|
||||
// 5. Verify Explicit Allows still work
|
||||
@@ -958,6 +958,25 @@ priority = 100
|
||||
readResult.decision,
|
||||
'Explicitly allowed tools (read_file) should be ALLOWED in Plan Mode',
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
// 6. Verify Built-in Research Subagents are ALLOWED
|
||||
const codebaseResult = await engine.check(
|
||||
{ name: 'codebase_investigator' },
|
||||
undefined,
|
||||
);
|
||||
expect(
|
||||
codebaseResult.decision,
|
||||
'codebase_investigator should be ALLOWED in Plan Mode',
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
const cliHelpResult = await engine.check(
|
||||
{ name: 'cli_help' },
|
||||
undefined,
|
||||
);
|
||||
expect(
|
||||
cliHelpResult.decision,
|
||||
'cli_help should be ALLOWED in Plan Mode',
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
} finally {
|
||||
await fs.rm(tempPolicyDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -946,7 +946,7 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
expect(mockStateManager.updateStatus).toHaveBeenCalledWith(
|
||||
'call-1',
|
||||
CoreToolCallStatus.Cancelled,
|
||||
'Operation cancelled',
|
||||
{ callId: 'call-1', responseParts: [] },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -741,7 +741,7 @@ export class Scheduler {
|
||||
this.state.updateStatus(
|
||||
callId,
|
||||
CoreToolCallStatus.Cancelled,
|
||||
'Operation cancelled',
|
||||
result.response,
|
||||
);
|
||||
} else {
|
||||
this.state.updateStatus(
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
MessageBusType,
|
||||
type SerializableConfirmationDetails,
|
||||
} from '../confirmation-bus/types.js';
|
||||
import { isToolCallResponseInfo } from '../utils/tool-utils.js';
|
||||
|
||||
/**
|
||||
* Handler for terminal tool calls.
|
||||
@@ -127,7 +128,7 @@ export class SchedulerStateManager {
|
||||
updateStatus(
|
||||
callId: string,
|
||||
status: CoreToolCallStatus.Cancelled,
|
||||
data: string,
|
||||
data: string | ToolCallResponseInfo,
|
||||
): void;
|
||||
updateStatus(
|
||||
callId: string,
|
||||
@@ -264,7 +265,7 @@ export class SchedulerStateManager {
|
||||
): ToolCall {
|
||||
switch (newStatus) {
|
||||
case CoreToolCallStatus.Success: {
|
||||
if (!this.isToolCallResponseInfo(auxiliaryData)) {
|
||||
if (!isToolCallResponseInfo(auxiliaryData)) {
|
||||
throw new Error(
|
||||
`Invalid data for 'success' transition (callId: ${call.request.callId})`,
|
||||
);
|
||||
@@ -272,7 +273,7 @@ export class SchedulerStateManager {
|
||||
return this.toSuccess(call, auxiliaryData);
|
||||
}
|
||||
case CoreToolCallStatus.Error: {
|
||||
if (!this.isToolCallResponseInfo(auxiliaryData)) {
|
||||
if (!isToolCallResponseInfo(auxiliaryData)) {
|
||||
throw new Error(
|
||||
`Invalid data for 'error' transition (callId: ${call.request.callId})`,
|
||||
);
|
||||
@@ -290,9 +291,12 @@ export class SchedulerStateManager {
|
||||
case CoreToolCallStatus.Scheduled:
|
||||
return this.toScheduled(call);
|
||||
case CoreToolCallStatus.Cancelled: {
|
||||
if (typeof auxiliaryData !== 'string') {
|
||||
if (
|
||||
typeof auxiliaryData !== 'string' &&
|
||||
!isToolCallResponseInfo(auxiliaryData)
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid reason (string) for 'cancelled' transition (callId: ${call.request.callId})`,
|
||||
`Invalid reason (string) or response for 'cancelled' transition (callId: ${call.request.callId})`,
|
||||
);
|
||||
}
|
||||
return this.toCancelled(call, auxiliaryData);
|
||||
@@ -317,15 +321,6 @@ export class SchedulerStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
private isToolCallResponseInfo(data: unknown): data is ToolCallResponseInfo {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'callId' in data &&
|
||||
'responseParts' in data
|
||||
);
|
||||
}
|
||||
|
||||
private isExecutingToolCallPatch(
|
||||
data: unknown,
|
||||
): data is Partial<ExecutingToolCall> {
|
||||
@@ -451,7 +446,10 @@ export class SchedulerStateManager {
|
||||
};
|
||||
}
|
||||
|
||||
private toCancelled(call: ToolCall, reason: string): CancelledToolCall {
|
||||
private toCancelled(
|
||||
call: ToolCall,
|
||||
reason: string | ToolCallResponseInfo,
|
||||
): CancelledToolCall {
|
||||
this.validateHasToolAndInvocation(call, CoreToolCallStatus.Cancelled);
|
||||
const startTime = 'startTime' in call ? call.startTime : undefined;
|
||||
|
||||
@@ -478,6 +476,20 @@ export class SchedulerStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (isToolCallResponseInfo(reason)) {
|
||||
return {
|
||||
request: call.request,
|
||||
tool: call.tool,
|
||||
invocation: call.invocation,
|
||||
status: CoreToolCallStatus.Cancelled,
|
||||
response: reason,
|
||||
durationMs: startTime ? Date.now() - startTime : undefined,
|
||||
outcome: call.outcome,
|
||||
schedulerId: call.schedulerId,
|
||||
approvalMode: call.approvalMode,
|
||||
};
|
||||
}
|
||||
|
||||
const errorMessage = `[Operation Cancelled] Reason: ${reason}`;
|
||||
return {
|
||||
request: call.request,
|
||||
|
||||
@@ -534,4 +534,113 @@ describe('ToolExecutor', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return cancelled result with partial output when signal is aborted', async () => {
|
||||
const mockTool = new MockTool({
|
||||
name: 'slowTool',
|
||||
});
|
||||
const invocation = mockTool.build({});
|
||||
|
||||
const partialOutput = 'Some partial output before cancellation';
|
||||
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockImplementation(
|
||||
async () => ({
|
||||
llmContent: partialOutput,
|
||||
returnDisplay: `[Cancelled] ${partialOutput}`,
|
||||
}),
|
||||
);
|
||||
|
||||
const scheduledCall: ScheduledToolCall = {
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
request: {
|
||||
callId: 'call-cancel-partial',
|
||||
name: 'slowTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-cancel',
|
||||
},
|
||||
tool: mockTool,
|
||||
invocation: invocation as unknown as AnyToolInvocation,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const result = await executor.execute({
|
||||
call: scheduledCall,
|
||||
signal: controller.signal,
|
||||
onUpdateToolCall: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result.status).toBe(CoreToolCallStatus.Cancelled);
|
||||
if (result.status === CoreToolCallStatus.Cancelled) {
|
||||
const response = result.response.responseParts[0]?.functionResponse
|
||||
?.response as Record<string, unknown>;
|
||||
expect(response).toEqual({
|
||||
error: '[Operation Cancelled] User cancelled tool execution.',
|
||||
output: partialOutput,
|
||||
});
|
||||
expect(result.response.resultDisplay).toBe(
|
||||
`[Cancelled] ${partialOutput}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should truncate large shell output even on cancellation', async () => {
|
||||
// 1. Setup Config for Truncation
|
||||
vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10);
|
||||
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp');
|
||||
|
||||
const mockTool = new MockTool({ name: SHELL_TOOL_NAME });
|
||||
const invocation = mockTool.build({});
|
||||
const longOutput = 'This is a very long output that should be truncated.';
|
||||
|
||||
// 2. Mock execution returning long content
|
||||
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
|
||||
llmContent: longOutput,
|
||||
returnDisplay: longOutput,
|
||||
});
|
||||
|
||||
const scheduledCall: ScheduledToolCall = {
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
request: {
|
||||
callId: 'call-trunc-cancel',
|
||||
name: SHELL_TOOL_NAME,
|
||||
args: { command: 'echo long' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-trunc-cancel',
|
||||
},
|
||||
tool: mockTool,
|
||||
invocation: invocation as unknown as AnyToolInvocation,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
// 3. Abort immediately
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
// 4. Execute
|
||||
const result = await executor.execute({
|
||||
call: scheduledCall,
|
||||
signal: controller.signal,
|
||||
onUpdateToolCall: vi.fn(),
|
||||
});
|
||||
|
||||
// 5. Verify Truncation Logic was applied in cancelled path
|
||||
expect(fileUtils.saveTruncatedToolOutput).toHaveBeenCalledWith(
|
||||
longOutput,
|
||||
SHELL_TOOL_NAME,
|
||||
'call-trunc-cancel',
|
||||
expect.any(String),
|
||||
'test-session-id',
|
||||
);
|
||||
|
||||
expect(result.status).toBe(CoreToolCallStatus.Cancelled);
|
||||
if (result.status === CoreToolCallStatus.Cancelled) {
|
||||
const response = result.response.responseParts[0]?.functionResponse
|
||||
?.response as Record<string, unknown>;
|
||||
expect(response['output']).toBe('TruncatedContent...');
|
||||
expect(result.response.outputFile).toBe('/tmp/truncated_output.txt');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
ToolCallResponseInfo,
|
||||
ToolResult,
|
||||
Config,
|
||||
ToolResultDisplay,
|
||||
ToolLiveOutput,
|
||||
} from '../index.js';
|
||||
import {
|
||||
@@ -19,8 +18,8 @@ import {
|
||||
runInDevTraceSpan,
|
||||
} from '../index.js';
|
||||
import { SHELL_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
|
||||
import { ShellToolInvocation } from '../tools/shell.js';
|
||||
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
|
||||
import { executeToolWithHooks } from '../core/coreToolHookTriggers.js';
|
||||
import {
|
||||
saveTruncatedToolOutput,
|
||||
@@ -36,6 +35,7 @@ import type {
|
||||
CancelledToolCall,
|
||||
} from './types.js';
|
||||
import { CoreToolCallStatus } from './types.js';
|
||||
import type { PartListUnion, Part } from '@google/genai';
|
||||
import {
|
||||
GeminiCliOperation,
|
||||
GEN_AI_TOOL_CALL_ID,
|
||||
@@ -132,10 +132,10 @@ export class ToolExecutor {
|
||||
const toolResult: ToolResult = await promise;
|
||||
|
||||
if (signal.aborted) {
|
||||
completedToolCall = this.createCancelledResult(
|
||||
completedToolCall = await this.createCancelledResult(
|
||||
call,
|
||||
'User cancelled tool execution.',
|
||||
toolResult.returnDisplay,
|
||||
toolResult,
|
||||
);
|
||||
} else if (toolResult.error === undefined) {
|
||||
completedToolCall = await this.createSuccessResult(
|
||||
@@ -163,7 +163,7 @@ export class ToolExecutor {
|
||||
executionError.message.includes('Operation cancelled by user'));
|
||||
|
||||
if (signal.aborted || isAbortError) {
|
||||
completedToolCall = this.createCancelledResult(
|
||||
completedToolCall = await this.createCancelledResult(
|
||||
call,
|
||||
'User cancelled tool execution.',
|
||||
);
|
||||
@@ -186,56 +186,13 @@ export class ToolExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
private createCancelledResult(
|
||||
private async truncateOutputIfNeeded(
|
||||
call: ToolCall,
|
||||
reason: string,
|
||||
resultDisplay?: ToolResultDisplay,
|
||||
): CancelledToolCall {
|
||||
const errorMessage = `[Operation Cancelled] ${reason}`;
|
||||
const startTime = 'startTime' in call ? call.startTime : undefined;
|
||||
|
||||
if (!('tool' in call) || !('invocation' in call)) {
|
||||
// This should effectively never happen in execution phase, but we handle
|
||||
// it safely
|
||||
throw new Error('Cancelled tool call missing tool/invocation references');
|
||||
}
|
||||
|
||||
return {
|
||||
status: CoreToolCallStatus.Cancelled,
|
||||
request: call.request,
|
||||
response: {
|
||||
callId: call.request.callId,
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: call.request.callId,
|
||||
name: call.request.name,
|
||||
response: { error: errorMessage },
|
||||
},
|
||||
},
|
||||
],
|
||||
resultDisplay,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: errorMessage.length,
|
||||
},
|
||||
tool: call.tool,
|
||||
invocation: call.invocation,
|
||||
durationMs: startTime ? Date.now() - startTime : undefined,
|
||||
startTime,
|
||||
endTime: Date.now(),
|
||||
outcome: call.outcome,
|
||||
};
|
||||
}
|
||||
|
||||
private async createSuccessResult(
|
||||
call: ToolCall,
|
||||
toolResult: ToolResult,
|
||||
): Promise<SuccessfulToolCall> {
|
||||
let content = toolResult.llmContent;
|
||||
let outputFile: string | undefined;
|
||||
const toolName = call.request.originalRequestName || call.request.name;
|
||||
content: PartListUnion,
|
||||
): Promise<{ truncatedContent: PartListUnion; outputFile?: string }> {
|
||||
const toolName = call.request.name;
|
||||
const callId = call.request.callId;
|
||||
let outputFile: string | undefined;
|
||||
|
||||
if (typeof content === 'string' && toolName === SHELL_TOOL_NAME) {
|
||||
const threshold = this.config.getTruncateToolOutputThreshold();
|
||||
@@ -250,17 +207,23 @@ export class ToolExecutor {
|
||||
this.config.getSessionId(),
|
||||
);
|
||||
outputFile = savedPath;
|
||||
content = formatTruncatedToolOutput(content, outputFile, threshold);
|
||||
const truncatedContent = formatTruncatedToolOutput(
|
||||
content,
|
||||
outputFile,
|
||||
threshold,
|
||||
);
|
||||
|
||||
logToolOutputTruncated(
|
||||
this.config,
|
||||
new ToolOutputTruncatedEvent(call.request.prompt_id, {
|
||||
toolName,
|
||||
originalContentLength,
|
||||
truncatedContentLength: content.length,
|
||||
truncatedContentLength: truncatedContent.length,
|
||||
threshold,
|
||||
}),
|
||||
);
|
||||
|
||||
return { truncatedContent, outputFile };
|
||||
}
|
||||
} else if (
|
||||
Array.isArray(content) &&
|
||||
@@ -288,7 +251,12 @@ export class ToolExecutor {
|
||||
outputFile,
|
||||
threshold,
|
||||
);
|
||||
content[0] = { ...firstPart, text: truncatedText };
|
||||
|
||||
// We need to return a NEW array to avoid mutating the original toolResult if it matters,
|
||||
// though here we are creating the response so it's probably fine to mutate or return new.
|
||||
const truncatedContent: Part[] = [
|
||||
{ ...firstPart, text: truncatedText },
|
||||
];
|
||||
|
||||
logToolOutputTruncated(
|
||||
this.config,
|
||||
@@ -299,10 +267,95 @@ export class ToolExecutor {
|
||||
threshold,
|
||||
}),
|
||||
);
|
||||
|
||||
return { truncatedContent, outputFile };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { truncatedContent: content, outputFile };
|
||||
}
|
||||
|
||||
private async createCancelledResult(
|
||||
call: ToolCall,
|
||||
reason: string,
|
||||
toolResult?: ToolResult,
|
||||
): Promise<CancelledToolCall> {
|
||||
const errorMessage = `[Operation Cancelled] ${reason}`;
|
||||
const startTime = 'startTime' in call ? call.startTime : undefined;
|
||||
|
||||
if (!('tool' in call) || !('invocation' in call)) {
|
||||
// This should effectively never happen in execution phase, but we handle
|
||||
// it safely
|
||||
throw new Error('Cancelled tool call missing tool/invocation references');
|
||||
}
|
||||
|
||||
let responseParts: Part[] = [];
|
||||
let outputFile: string | undefined;
|
||||
|
||||
if (toolResult?.llmContent) {
|
||||
// Attempt to truncate and save output if we have content, even in cancellation case
|
||||
// This is to handle cases where the tool may have produced output before cancellation
|
||||
const { truncatedContent: output, outputFile: truncatedOutputFile } =
|
||||
await this.truncateOutputIfNeeded(call, toolResult?.llmContent);
|
||||
|
||||
outputFile = truncatedOutputFile;
|
||||
responseParts = convertToFunctionResponse(
|
||||
call.request.name,
|
||||
call.request.callId,
|
||||
output,
|
||||
this.config.getActiveModel(),
|
||||
);
|
||||
|
||||
// Inject the cancellation error into the response object
|
||||
const mainPart = responseParts[0];
|
||||
if (mainPart?.functionResponse?.response) {
|
||||
const respObj = mainPart.functionResponse.response;
|
||||
respObj['error'] = errorMessage;
|
||||
}
|
||||
} else {
|
||||
responseParts = [
|
||||
{
|
||||
functionResponse: {
|
||||
id: call.request.callId,
|
||||
name: call.request.name,
|
||||
response: { error: errorMessage },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
status: CoreToolCallStatus.Cancelled,
|
||||
request: call.request,
|
||||
response: {
|
||||
callId: call.request.callId,
|
||||
responseParts,
|
||||
resultDisplay: toolResult?.returnDisplay,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
outputFile,
|
||||
contentLength: JSON.stringify(responseParts).length,
|
||||
},
|
||||
tool: call.tool,
|
||||
invocation: call.invocation,
|
||||
durationMs: startTime ? Date.now() - startTime : undefined,
|
||||
startTime,
|
||||
endTime: Date.now(),
|
||||
outcome: call.outcome,
|
||||
};
|
||||
}
|
||||
|
||||
private async createSuccessResult(
|
||||
call: ToolCall,
|
||||
toolResult: ToolResult,
|
||||
): Promise<SuccessfulToolCall> {
|
||||
const { truncatedContent: content, outputFile } =
|
||||
await this.truncateOutputIfNeeded(call, toolResult.llmContent);
|
||||
|
||||
const toolName = call.request.originalRequestName || call.request.name;
|
||||
const callId = call.request.callId;
|
||||
|
||||
const response = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
|
||||
@@ -309,23 +309,33 @@ describe('ChatRecordingService', () => {
|
||||
});
|
||||
|
||||
describe('deleteSession', () => {
|
||||
it('should delete the session file and tool outputs if they exist', () => {
|
||||
it('should delete the session file, tool outputs, session directory, and logs if they exist', () => {
|
||||
const sessionId = 'test-session-id';
|
||||
const chatsDir = path.join(testTempDir, 'chats');
|
||||
const logsDir = path.join(testTempDir, 'logs');
|
||||
const toolOutputsDir = path.join(testTempDir, 'tool-outputs');
|
||||
const sessionDir = path.join(testTempDir, sessionId);
|
||||
|
||||
fs.mkdirSync(chatsDir, { recursive: true });
|
||||
const sessionFile = path.join(chatsDir, 'test-session-id.json');
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
fs.mkdirSync(toolOutputsDir, { recursive: true });
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
|
||||
const sessionFile = path.join(chatsDir, `${sessionId}.json`);
|
||||
fs.writeFileSync(sessionFile, '{}');
|
||||
|
||||
const toolOutputDir = path.join(
|
||||
testTempDir,
|
||||
'tool-outputs',
|
||||
'session-test-session-id',
|
||||
);
|
||||
const logFile = path.join(logsDir, `session-${sessionId}.jsonl`);
|
||||
fs.writeFileSync(logFile, '{}');
|
||||
|
||||
const toolOutputDir = path.join(toolOutputsDir, `session-${sessionId}`);
|
||||
fs.mkdirSync(toolOutputDir, { recursive: true });
|
||||
|
||||
chatRecordingService.deleteSession('test-session-id');
|
||||
chatRecordingService.deleteSession(sessionId);
|
||||
|
||||
expect(fs.existsSync(sessionFile)).toBe(false);
|
||||
expect(fs.existsSync(logFile)).toBe(false);
|
||||
expect(fs.existsSync(toolOutputDir)).toBe(false);
|
||||
expect(fs.existsSync(sessionDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not throw if session file does not exist', () => {
|
||||
|
||||
@@ -569,6 +569,13 @@ export class ChatRecordingService {
|
||||
fs.unlinkSync(sessionPath);
|
||||
}
|
||||
|
||||
// Cleanup Activity logs in the project logs directory
|
||||
const logsDir = path.join(tempDir, 'logs');
|
||||
const logPath = path.join(logsDir, `session-${sessionId}.jsonl`);
|
||||
if (fs.existsSync(logPath)) {
|
||||
fs.unlinkSync(logPath);
|
||||
}
|
||||
|
||||
// Cleanup tool outputs for this session
|
||||
const safeSessionId = sanitizeFilenamePart(sessionId);
|
||||
const toolOutputDir = path.join(
|
||||
@@ -585,6 +592,13 @@ export class ChatRecordingService {
|
||||
) {
|
||||
fs.rmSync(toolOutputDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ALSO cleanup the session-specific directory (contains plans, tasks, etc.)
|
||||
const sessionDir = path.join(tempDir, safeSessionId);
|
||||
// Robustness: Ensure the path is strictly within the temp root
|
||||
if (fs.existsSync(sessionDir) && sessionDir.startsWith(tempDir)) {
|
||||
fs.rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error('Error deleting session file.', error);
|
||||
throw error;
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('generateValidName', () => {
|
||||
|
||||
it('should truncate long names', () => {
|
||||
expect(generateValidName('x'.repeat(80))).toBe(
|
||||
'xxxxxxxxxxxxxxxxxxxxxxxxxxxx___xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -933,3 +933,82 @@ describe('DiscoveredMCPTool', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP Tool Naming Regression Fixes', () => {
|
||||
describe('generateValidName', () => {
|
||||
it('should replace spaces with underscores', () => {
|
||||
expect(generateValidName('My Tool')).toBe('My_Tool');
|
||||
});
|
||||
|
||||
it('should allow colons', () => {
|
||||
expect(generateValidName('namespace:tool')).toBe('namespace:tool');
|
||||
});
|
||||
|
||||
it('should ensure name starts with a letter or underscore', () => {
|
||||
expect(generateValidName('123-tool')).toBe('_123-tool');
|
||||
expect(generateValidName('-tool')).toBe('_-tool');
|
||||
expect(generateValidName('.tool')).toBe('_.tool');
|
||||
});
|
||||
|
||||
it('should handle very long names by truncating in the middle', () => {
|
||||
const longName = 'a'.repeat(40) + '__' + 'b'.repeat(40);
|
||||
const result = generateValidName(longName);
|
||||
expect(result.length).toBeLessThanOrEqual(63);
|
||||
expect(result).toMatch(/^a{30}\.\.\.b{30}$/);
|
||||
});
|
||||
|
||||
it('should handle very long names starting with a digit', () => {
|
||||
const longName = '1' + 'a'.repeat(80);
|
||||
const result = generateValidName(longName);
|
||||
expect(result.length).toBeLessThanOrEqual(63);
|
||||
expect(result.startsWith('_1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DiscoveredMCPTool qualified names', () => {
|
||||
it('should generate a valid qualified name even with spaces in server name', () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
{} as any,
|
||||
'My Server',
|
||||
'my-tool',
|
||||
'desc',
|
||||
{},
|
||||
{} as any,
|
||||
);
|
||||
|
||||
const qn = tool.getFullyQualifiedName();
|
||||
expect(qn).toBe('My_Server__my-tool');
|
||||
});
|
||||
|
||||
it('should handle long server and tool names in qualified name', () => {
|
||||
const serverName = 'a'.repeat(40);
|
||||
const toolName = 'b'.repeat(40);
|
||||
const tool = new DiscoveredMCPTool(
|
||||
{} as any,
|
||||
serverName,
|
||||
toolName,
|
||||
'desc',
|
||||
{},
|
||||
{} as any,
|
||||
);
|
||||
|
||||
const qn = tool.getFullyQualifiedName();
|
||||
expect(qn.length).toBeLessThanOrEqual(63);
|
||||
expect(qn).toContain('...');
|
||||
});
|
||||
|
||||
it('should handle server names starting with digits', () => {
|
||||
const tool = new DiscoveredMCPTool(
|
||||
{} as any,
|
||||
'123-server',
|
||||
'tool',
|
||||
'desc',
|
||||
{},
|
||||
{} as any,
|
||||
);
|
||||
|
||||
const qn = tool.getFullyQualifiedName();
|
||||
expect(qn).toBe('_123-server__tool');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,14 +96,17 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
|
||||
) {
|
||||
// Use composite format for policy checks: serverName__toolName
|
||||
// This enables server wildcards (e.g., "google-workspace__*")
|
||||
// while still allowing specific tool rules
|
||||
// while still allowing specific tool rules.
|
||||
// We use the same sanitized names as the registry to ensure policy matches.
|
||||
|
||||
super(
|
||||
params,
|
||||
messageBus,
|
||||
`${serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${serverToolName}`,
|
||||
generateValidName(
|
||||
`${serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${serverToolName}`,
|
||||
),
|
||||
displayName,
|
||||
serverName,
|
||||
generateValidName(serverName),
|
||||
toolAnnotationsData,
|
||||
);
|
||||
}
|
||||
@@ -273,7 +276,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
|
||||
private readonly _toolAnnotations?: Record<string, unknown>,
|
||||
) {
|
||||
super(
|
||||
nameOverride ?? generateValidName(serverToolName),
|
||||
generateValidName(nameOverride ?? serverToolName),
|
||||
`${serverToolName} (${serverName} MCP Server)`,
|
||||
description,
|
||||
Kind.Other,
|
||||
@@ -305,7 +308,9 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
|
||||
}
|
||||
|
||||
getFullyQualifiedName(): string {
|
||||
return `${this.getFullyQualifiedPrefix()}${generateValidName(this.serverToolName)}`;
|
||||
return generateValidName(
|
||||
`${this.serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${this.serverToolName}`,
|
||||
);
|
||||
}
|
||||
|
||||
asFullyQualifiedTool(): DiscoveredMCPTool {
|
||||
@@ -482,16 +487,29 @@ function getStringifiedResultForDisplay(rawResponse: Part[]): string {
|
||||
return displayParts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum length for a function name in the Gemini API.
|
||||
* @see https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/function-calling#functiondeclaration
|
||||
*/
|
||||
const MAX_FUNCTION_NAME_LENGTH = 64;
|
||||
|
||||
/** Visible for testing */
|
||||
export function generateValidName(name: string) {
|
||||
// Replace invalid characters (based on 400 error message from Gemini API) with underscores
|
||||
let validToolname = name.replace(/[^a-zA-Z0-9_.-]/g, '_');
|
||||
let validToolname = name.replace(/[^a-zA-Z0-9_.:-]/g, '_');
|
||||
|
||||
// If longer than 63 characters, replace middle with '___'
|
||||
// (Gemini API says max length 64, but actual limit seems to be 63)
|
||||
if (validToolname.length > 63) {
|
||||
validToolname =
|
||||
validToolname.slice(0, 28) + '___' + validToolname.slice(-32);
|
||||
// Ensure it starts with a letter or underscore
|
||||
if (/^[^a-zA-Z_]/.test(validToolname)) {
|
||||
validToolname = `_${validToolname}`;
|
||||
}
|
||||
|
||||
// If longer than the API limit, replace middle with '...'
|
||||
// Note: We use 63 instead of 64 to be safe, as some environments have off-by-one behaviors.
|
||||
const safeLimit = MAX_FUNCTION_NAME_LENGTH - 1;
|
||||
if (validToolname.length > safeLimit) {
|
||||
validToolname =
|
||||
validToolname.slice(0, 30) + '...' + validToolname.slice(-30);
|
||||
}
|
||||
|
||||
return validToolname;
|
||||
}
|
||||
|
||||
@@ -388,16 +388,17 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
} else {
|
||||
if (this.params.is_background || result.backgrounded) {
|
||||
returnDisplayMessage = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
|
||||
} else if (result.aborted) {
|
||||
const cancelMsg = timeoutMessage || 'Command cancelled by user.';
|
||||
if (result.output.trim()) {
|
||||
returnDisplayMessage = `${cancelMsg}\n\nOutput before cancellation:\n${result.output}`;
|
||||
} else {
|
||||
returnDisplayMessage = cancelMsg;
|
||||
}
|
||||
} else if (result.output.trim()) {
|
||||
returnDisplayMessage = result.output;
|
||||
} else {
|
||||
if (result.aborted) {
|
||||
if (timeoutMessage) {
|
||||
returnDisplayMessage = timeoutMessage;
|
||||
} else {
|
||||
returnDisplayMessage = 'Command cancelled by user.';
|
||||
}
|
||||
} else if (result.signal) {
|
||||
if (result.signal) {
|
||||
returnDisplayMessage = `Command terminated by signal: ${result.signal}`;
|
||||
} else if (result.error) {
|
||||
returnDisplayMessage = `Command failed: ${getErrorMessage(
|
||||
|
||||
@@ -58,6 +58,8 @@ describe('tool-names', () => {
|
||||
it('should validate MCP tool names (server__tool)', () => {
|
||||
expect(isValidToolName('server__tool')).toBe(true);
|
||||
expect(isValidToolName('my-server__my-tool')).toBe(true);
|
||||
expect(isValidToolName('my.server__my:tool')).toBe(true);
|
||||
expect(isValidToolName('my-server...truncated__tool')).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate legacy tool aliases', async () => {
|
||||
|
||||
@@ -260,8 +260,10 @@ export function isValidToolName(
|
||||
return !!options.allowWildcards;
|
||||
}
|
||||
|
||||
// Basic slug validation for server and tool names
|
||||
const slugRegex = /^[a-z0-9-_]+$/i;
|
||||
// Basic slug validation for server and tool names.
|
||||
// We allow dots (.) and colons (:) as they are valid in function names and
|
||||
// used for truncation markers.
|
||||
const slugRegex = /^[a-z0-9_.:-]+$/i;
|
||||
return slugRegex.test(server) && slugRegex.test(tool);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,30 @@ import { isTool } from '../index.js';
|
||||
import { SHELL_TOOL_NAMES } from './shell-utils.js';
|
||||
import levenshtein from 'fast-levenshtein';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import { CoreToolCallStatus } from '../scheduler/types.js';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
type ToolCallResponseInfo,
|
||||
} from '../scheduler/types.js';
|
||||
import {
|
||||
ASK_USER_DISPLAY_NAME,
|
||||
WRITE_FILE_DISPLAY_NAME,
|
||||
EDIT_DISPLAY_NAME,
|
||||
} from '../tools/tool-names.js';
|
||||
|
||||
/**
|
||||
* Validates if an object is a ToolCallResponseInfo.
|
||||
*/
|
||||
export function isToolCallResponseInfo(
|
||||
data: unknown,
|
||||
): data is ToolCallResponseInfo {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'callId' in data &&
|
||||
'responseParts' in data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for determining if a tool call should be hidden in the CLI history.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.33.0-nightly.20260228.1ca5c05d0",
|
||||
"version": "0.33.0-preview.2",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.33.0-nightly.20260228.1ca5c05d0",
|
||||
"version": "0.33.0-preview.2",
|
||||
"description": "Gemini CLI SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.33.0-nightly.20260228.1ca5c05d0",
|
||||
"version": "0.33.0-preview.2",
|
||||
"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.33.0-nightly.20260228.1ca5c05d0",
|
||||
"version": "0.33.0-preview.2",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user