Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cad496650b | |||
| 38de78045d | |||
| dce25e4229 | |||
| 59bf8cbb45 |
@@ -7,15 +7,5 @@
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
},
|
||||
"agents": {
|
||||
"overrides": {
|
||||
"browser_agent": { "enabled": true }
|
||||
},
|
||||
"browser": {
|
||||
"headless": true,
|
||||
"sessionMode": "isolated",
|
||||
"allowedDomains": ["*.com"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
name: 'Evals: PR Guidance'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'packages/core/src/**/*.ts'
|
||||
- '!**/*.test.ts'
|
||||
- '!**/*.test.tsx'
|
||||
|
||||
permissions:
|
||||
pull-requests: 'write'
|
||||
contents: 'read'
|
||||
|
||||
jobs:
|
||||
provide-guidance:
|
||||
name: 'Model Steering Guidance'
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Set up Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Detect Steering Changes'
|
||||
id: 'detect'
|
||||
run: |
|
||||
STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only)
|
||||
echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Analyze PR Content'
|
||||
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
|
||||
id: 'analysis'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: |
|
||||
# Check for behavioral eval changes
|
||||
EVAL_CHANGES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep "^evals/" || true)
|
||||
if [ -z "$EVAL_CHANGES" ]; then
|
||||
echo "MISSING_EVALS=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Check if user is a maintainer (has write/admin access)
|
||||
USER_PERMISSION=$(gh api repos/${{ github.repository }}/collaborators/${{ github.actor }}/permission --jq '.permission')
|
||||
if [[ "$USER_PERMISSION" == "admin" || "$USER_PERMISSION" == "write" ]]; then
|
||||
echo "IS_MAINTAINER=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: 'Post Guidance Comment'
|
||||
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
|
||||
uses: 'thollander/actions-comment-pull-request@65f9e5c9a1f2cd378bd74b2e057c9736982a8e74' # ratchet:thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
comment-tag: 'eval-guidance-bot'
|
||||
message: |
|
||||
### 🧠 Model Steering Guidance
|
||||
|
||||
This PR modifies files that affect the model's behavior (prompts, tools, or instructions).
|
||||
|
||||
${{ steps.analysis.outputs.MISSING_EVALS == 'true' && '- ⚠️ **Consider adding Evals:** No behavioral evaluations (`evals/*.eval.ts`) were added or updated in this PR. Consider adding a test case to verify the new behavior and prevent regressions.' || '' }}
|
||||
${{ steps.analysis.outputs.IS_MAINTAINER == 'true' && '- 🚀 **Maintainer Reminder:** Please ensure that these changes do not regress results on benchmark evals before merging.' || '' }}
|
||||
|
||||
---
|
||||
*This is an automated guidance message triggered by steering logic signatures.*
|
||||
@@ -61,7 +61,6 @@ jobs:
|
||||
GEMINI_MODEL: '${{ matrix.model }}'
|
||||
RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}"
|
||||
TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}'
|
||||
VITEST_RETRY: 0
|
||||
run: |
|
||||
CMD="npm run test:all_evals"
|
||||
PATTERN="${TEST_NAME_PATTERN}"
|
||||
|
||||
@@ -30,7 +30,7 @@ Learn all about Gemini CLI in our [documentation](https://geminicli.com/docs/).
|
||||
## 📦 Installation
|
||||
|
||||
See
|
||||
[Gemini CLI installation, execution, and releases](./docs/get-started/installation.md)
|
||||
[Gemini CLI installation, execution, and releases](./docs/resources/installation.md)
|
||||
for recommended system specifications and a detailed installation guide.
|
||||
|
||||
### Quick Install
|
||||
@@ -209,7 +209,7 @@ gemini
|
||||
```
|
||||
|
||||
For Google Workspace accounts and other authentication methods, see the
|
||||
[authentication guide](./docs/get-started/authentication.md).
|
||||
[authentication guide](./docs/resources/authentication.md).
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
@@ -280,8 +280,8 @@ gemini
|
||||
|
||||
- [**Quickstart Guide**](./docs/get-started/index.md) - Get up and running
|
||||
quickly.
|
||||
- [**Authentication Setup**](./docs/get-started/authentication.md) - Detailed
|
||||
auth configuration.
|
||||
- [**Authentication Setup**](./docs/resources/authentication.md) - Detailed auth
|
||||
configuration.
|
||||
- [**Configuration Guide**](./docs/reference/configuration.md) - Settings and
|
||||
customization.
|
||||
- [**Keyboard Shortcuts**](./docs/reference/keyboard-shortcuts.md) -
|
||||
|
||||
@@ -508,7 +508,7 @@ other events. For more information, see the
|
||||
You can enforce a specific authentication method for all users by setting the
|
||||
`enforcedAuthType` in the system-level `settings.json` file. This prevents users
|
||||
from choosing a different authentication method. See the
|
||||
[Authentication docs](../get-started/authentication.md) for more details.
|
||||
[Authentication docs](../resources/authentication.md) for more details.
|
||||
|
||||
**Example:** Enforce the use of Google login for all users.
|
||||
|
||||
|
||||
@@ -101,13 +101,6 @@ they appear in the UI.
|
||||
| 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` |
|
||||
|
||||
### Agents
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------- | ------- |
|
||||
| Confirm Sensitive Actions | `agents.browser.confirmSensitiveActions` | Require manual confirmation for sensitive browser actions (e.g., fill_form, evaluate_script). | `false` |
|
||||
| Block File Uploads | `agents.browser.blockFileUploads` | Hard-block file upload requests from the browser agent. | `false` |
|
||||
|
||||
### Context
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
|
||||
@@ -24,7 +24,7 @@ project-specific behavior or create a customized persona.
|
||||
|
||||
You can set the environment variable temporarily in your shell, or persist it
|
||||
via a `.gemini/.env` file. See
|
||||
[Persisting Environment Variables](../get-started/authentication.md#persisting-environment-variables).
|
||||
[Persisting Environment Variables](../resources/authentication.md#persisting-environment-variables).
|
||||
|
||||
- Use the project default path (`.gemini/system.md`):
|
||||
- `GEMINI_SYSTEM_MD=true` or `GEMINI_SYSTEM_MD=1`
|
||||
|
||||
@@ -904,20 +904,6 @@ Logs keychain availability checks.
|
||||
|
||||
- `available` (boolean)
|
||||
|
||||
##### `gemini_cli.startup_stats`
|
||||
|
||||
Logs detailed startup performance statistics.
|
||||
|
||||
<details>
|
||||
<summary>Attributes</summary>
|
||||
|
||||
- `phases` (json array of startup phases)
|
||||
- `os_platform` (string)
|
||||
- `os_release` (string)
|
||||
- `is_docker` (boolean)
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
### Metrics
|
||||
@@ -934,20 +920,6 @@ Gemini CLI exports several custom metrics.
|
||||
|
||||
Incremented once per CLI startup.
|
||||
|
||||
##### Onboarding
|
||||
|
||||
Tracks onboarding flow from authentication to the user
|
||||
|
||||
- `gemini_cli.onboarding.start` (Counter, Int): Incremented when the
|
||||
authentication flow begins.
|
||||
|
||||
- `gemini_cli.onboarding.success` (Counter, Int): Incremented when the user
|
||||
onboarding flow completes successfully.
|
||||
<details>
|
||||
<summary>Attributes (Success)</summary>
|
||||
|
||||
- `user_tier` (string)
|
||||
|
||||
##### Tools
|
||||
|
||||
##### `gemini_cli.tool.call.count`
|
||||
|
||||
@@ -24,7 +24,8 @@ Once Gemini CLI is installed, run Gemini CLI from your command line:
|
||||
gemini
|
||||
```
|
||||
|
||||
For more installation options, see [Gemini CLI Installation](./installation.md).
|
||||
For more installation options, see
|
||||
[Gemini CLI Installation](../resources/installation.md).
|
||||
|
||||
## Authenticate
|
||||
|
||||
@@ -46,7 +47,7 @@ cases, you can log in with your existing Google account:
|
||||
|
||||
Certain account types may require you to configure a Google Cloud project. For
|
||||
more information, including other authentication methods, see
|
||||
[Gemini CLI Authentication Setup](./authentication.md).
|
||||
[Gemini CLI Authentication Setup](../resources/authentication.md).
|
||||
|
||||
## Configure
|
||||
|
||||
|
||||
@@ -470,5 +470,5 @@ console.error('Consolidating memories for session end...');
|
||||
|
||||
While project-level hooks are great for specific repositories, you can share
|
||||
your hooks across multiple projects by packaging them as a
|
||||
[Gemini CLI extension](../extensions/index.md). This provides version control,
|
||||
easy distribution, and centralized management.
|
||||
[Gemini CLI extension](https://www.google.com/search?q=../extensions/index.md).
|
||||
This provides version control, easy distribution, and centralized management.
|
||||
|
||||
@@ -10,15 +10,15 @@ context.
|
||||
npm install -g @google/gemini-cli
|
||||
```
|
||||
|
||||
For more installation options and authentication setup, see the full
|
||||
[Installation](./resources/installation.md) and
|
||||
[Authentication](./resources/authentication.md) guides.
|
||||
|
||||
## Get started
|
||||
|
||||
Jump in to Gemini CLI.
|
||||
|
||||
- **[Quickstart](./get-started/index.md):** Your first session with Gemini CLI.
|
||||
- **[Installation](./get-started/installation.md):** How to install Gemini CLI
|
||||
on your system.
|
||||
- **[Authentication](./get-started/authentication.md):** Setup instructions for
|
||||
personal and enterprise accounts.
|
||||
- **[Examples](./get-started/examples.md):** Practical examples of Gemini CLI in
|
||||
action.
|
||||
- **[CLI cheatsheet](./cli/cli-reference.md):** A quick reference for common
|
||||
@@ -116,6 +116,9 @@ Deep technical documentation and API specifications.
|
||||
Support, release history, and legal information.
|
||||
|
||||
- **[FAQ](./resources/faq.md):** Answers to frequently asked questions.
|
||||
- **[Installation](./resources/installation.md):** How to install Gemini CLI.
|
||||
- **[Authentication](./resources/authentication.md):** Setup instructions for
|
||||
personal and enterprise accounts.
|
||||
- **[Quota and pricing](./resources/quota-and-pricing.md):** Limits and billing
|
||||
details.
|
||||
- **[Terms and privacy](./resources/tos-privacy.md):** Official notices and
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
"/docs/core/tools-api": "/docs/reference/tools",
|
||||
"/docs/reference/tools-api": "/docs/reference/tools",
|
||||
"/docs/faq": "/docs/resources/faq",
|
||||
"/docs/get-started/authentication": "/docs/resources/authentication",
|
||||
"/docs/get-started/configuration": "/docs/reference/configuration",
|
||||
"/docs/get-started/configuration-v1": "/docs/reference/configuration",
|
||||
"/docs/get-started/installation": "/docs/resources/installation",
|
||||
"/docs/index": "/docs",
|
||||
"/docs/quota-and-pricing": "/docs/resources/quota-and-pricing",
|
||||
"/docs/tos-privacy": "/docs/resources/tos-privacy",
|
||||
|
||||
@@ -1210,17 +1210,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Disable user input on browser window during automation.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`agents.browser.confirmSensitiveActions`** (boolean):
|
||||
- **Description:** Require manual confirmation for sensitive browser actions
|
||||
(e.g., fill_form, evaluate_script).
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`agents.browser.blockFileUploads`** (boolean):
|
||||
- **Description:** Hard-block file upload requests from the browser agent.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `context`
|
||||
|
||||
- **`context.fileName`** (string | string[]):
|
||||
@@ -1914,8 +1903,8 @@ within your user's home folder.
|
||||
Environment variables are a common way to configure applications, especially for
|
||||
sensitive information like API keys or for settings that might change between
|
||||
environments. For authentication setup, see the
|
||||
[Authentication documentation](../get-started/authentication.md) which covers
|
||||
all available authentication methods.
|
||||
[Authentication documentation](../resources/authentication.md) which covers all
|
||||
available authentication methods.
|
||||
|
||||
The CLI automatically loads environment variables from an `.env` file. The
|
||||
loading order is:
|
||||
@@ -1935,7 +1924,7 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
|
||||
- **`GEMINI_API_KEY`**:
|
||||
- Your API key for the Gemini API.
|
||||
- One of several available
|
||||
[authentication methods](../get-started/authentication.md).
|
||||
[authentication methods](../resources/authentication.md).
|
||||
- Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env`
|
||||
file.
|
||||
- **`GEMINI_MODEL`**:
|
||||
|
||||
@@ -262,8 +262,8 @@ Here is a breakdown of the fields available in a TOML policy rule:
|
||||
# A unique name for the tool, or an array of names.
|
||||
toolName = "run_shell_command"
|
||||
|
||||
# (Optional) The name of a subagent. If provided, the rule only applies to tool
|
||||
# calls made by this specific subagent.
|
||||
# (Optional) The name of a subagent. If provided, the rule only applies to tool calls
|
||||
# made by this specific subagent.
|
||||
subagent = "generalist"
|
||||
|
||||
# (Optional) The name of an MCP server. Can be combined with toolName
|
||||
@@ -278,17 +278,14 @@ toolAnnotations = { readOnlyHint = true }
|
||||
argsPattern = '"command":"(git|npm)'
|
||||
|
||||
# (Optional) A string or array of strings that a shell command must start with.
|
||||
# This is syntactic sugar for `toolName = "run_shell_command"` and an
|
||||
# `argsPattern`.
|
||||
# This is syntactic sugar for `toolName = "run_shell_command"` and an `argsPattern`.
|
||||
commandPrefix = "git"
|
||||
|
||||
# (Optional) A regex to match against the entire shell command.
|
||||
# This is also syntactic sugar for `toolName = "run_shell_command"`.
|
||||
# Note: This pattern is tested against the JSON representation of the arguments
|
||||
# (e.g., `{"command":"<your_command>"}`). Because it prepends `"command":"`,
|
||||
# it effectively matches from the start of the command.
|
||||
# Anchors like `^` or `$` apply to the full JSON string,
|
||||
# so `^` should usually be avoided here.
|
||||
# Note: This pattern is tested against the JSON representation of the arguments (e.g., `{"command":"<your_command>"}`).
|
||||
# Because it prepends `"command":"`, it effectively matches from the start of the command.
|
||||
# Anchors like `^` or `$` apply to the full JSON string, so `^` should usually be avoided here.
|
||||
# You cannot use commandPrefix and commandRegex in the same rule.
|
||||
commandRegex = "git (commit|push)"
|
||||
|
||||
@@ -298,16 +295,14 @@ decision = "ask_user"
|
||||
# The priority of the rule, from 0 to 999.
|
||||
priority = 10
|
||||
|
||||
# (Optional) A custom message to display when a tool call is denied by this
|
||||
# rule. This message is returned to the model and user,
|
||||
# useful for explaining *why* it was denied.
|
||||
# (Optional) A custom message to display when a tool call is denied by this rule.
|
||||
# This message is returned to the model and user, useful for explaining *why* it was denied.
|
||||
deny_message = "Deletion is permanent"
|
||||
|
||||
# (Optional) An array of approval modes where this rule is active.
|
||||
modes = ["autoEdit"]
|
||||
|
||||
# (Optional) A boolean to restrict the rule to interactive (true) or
|
||||
# non-interactive (false) environments.
|
||||
# (Optional) A boolean to restrict the rule to interactive (true) or non-interactive (false) environments.
|
||||
# If omitted, the rule applies to both.
|
||||
interactive = true
|
||||
```
|
||||
|
||||
@@ -7,11 +7,6 @@
|
||||
"items": [
|
||||
{ "label": "Overview", "slug": "docs" },
|
||||
{ "label": "Quickstart", "slug": "docs/get-started" },
|
||||
{ "label": "Installation", "slug": "docs/get-started/installation" },
|
||||
{
|
||||
"label": "Authentication",
|
||||
"slug": "docs/get-started/authentication"
|
||||
},
|
||||
{ "label": "Examples", "slug": "docs/get-started/examples" },
|
||||
{ "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" },
|
||||
{
|
||||
@@ -220,6 +215,11 @@
|
||||
"label": "Resources",
|
||||
"items": [
|
||||
{ "label": "FAQ", "slug": "docs/resources/faq" },
|
||||
{ "label": "Installation", "slug": "docs/resources/installation" },
|
||||
{
|
||||
"label": "Authentication",
|
||||
"slug": "docs/resources/authentication"
|
||||
},
|
||||
{
|
||||
"label": "Quota and pricing",
|
||||
"slug": "docs/resources/quota-and-pricing"
|
||||
|
||||
@@ -35,12 +35,6 @@ const commonRestrictedSyntaxRules = [
|
||||
message:
|
||||
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
'UnaryExpression[operator="typeof"] > MemberExpression[computed=true][property.type="Literal"]',
|
||||
message:
|
||||
'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.',
|
||||
},
|
||||
];
|
||||
|
||||
export default tseslint.config(
|
||||
@@ -139,7 +133,16 @@ export default tseslint.config(
|
||||
'no-cond-assign': 'error',
|
||||
'no-debugger': 'error',
|
||||
'no-duplicate-case': 'error',
|
||||
'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules],
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
...commonRestrictedSyntaxRules,
|
||||
{
|
||||
selector:
|
||||
'UnaryExpression[operator="typeof"] > MemberExpression[computed=true][property.type="Literal"]',
|
||||
message:
|
||||
'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.',
|
||||
},
|
||||
],
|
||||
'no-unsafe-finally': 'error',
|
||||
'no-unused-expressions': 'off', // Disable base rule
|
||||
'@typescript-eslint/no-unused-expressions': [
|
||||
@@ -158,7 +161,6 @@ export default tseslint.config(
|
||||
'@typescript-eslint/await-thenable': ['error'],
|
||||
'@typescript-eslint/no-floating-promises': ['error'],
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': ['error'],
|
||||
'@typescript-eslint/no-misused-spread': ['error'],
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
|
||||
@@ -15,26 +15,9 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DEFAULT_GEMINI_MODEL } from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Config overrides for evals, with tool-restriction fields explicitly
|
||||
* forbidden. Evals must test against the full, default tool set to ensure
|
||||
* realistic behavior.
|
||||
*/
|
||||
interface EvalConfigOverrides {
|
||||
/** Restricting tools via excludeTools in evals is forbidden. */
|
||||
excludeTools?: never;
|
||||
/** Restricting tools via coreTools in evals is forbidden. */
|
||||
coreTools?: never;
|
||||
/** Restricting tools via allowedTools in evals is forbidden. */
|
||||
allowedTools?: never;
|
||||
/** Restricting tools via mainAgentTools in evals is forbidden. */
|
||||
mainAgentTools?: never;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AppEvalCase {
|
||||
name: string;
|
||||
configOverrides?: EvalConfigOverrides;
|
||||
configOverrides?: any;
|
||||
prompt: string;
|
||||
timeout?: number;
|
||||
files?: Record<string, string>;
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('CliHelpAgent Delegation', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should delegate to cli_help agent for subagent creation questions',
|
||||
params: {
|
||||
settings: {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: 'Help me create a subagent in this project',
|
||||
timeout: 60000,
|
||||
assert: async (rig, _result) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const toolCallIndex = toolLogs.findIndex(
|
||||
(log) => log.toolRequest.name === 'cli_help',
|
||||
);
|
||||
expect(toolCallIndex).toBeGreaterThan(-1);
|
||||
expect(toolCallIndex).toBeLessThan(5); // Called within first 5 turns
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ describe('generalist_delegation', () => {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
},
|
||||
excludeTools: ['run_shell_command'],
|
||||
},
|
||||
files: {
|
||||
'file1.ts': 'console.log("no semi")',
|
||||
@@ -64,6 +65,7 @@ describe('generalist_delegation', () => {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
},
|
||||
excludeTools: ['run_shell_command'],
|
||||
},
|
||||
files: {
|
||||
'src/a.ts': 'export const a = 1;',
|
||||
@@ -104,6 +106,7 @@ describe('generalist_delegation', () => {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
},
|
||||
excludeTools: ['run_shell_command'],
|
||||
},
|
||||
files: {
|
||||
'README.md': 'This is a proyect.',
|
||||
@@ -138,6 +141,7 @@ describe('generalist_delegation', () => {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
},
|
||||
excludeTools: ['run_shell_command'],
|
||||
},
|
||||
files: {
|
||||
'src/VERSION': '1.2.3',
|
||||
|
||||
@@ -12,9 +12,10 @@ import { appEvalTest } from './app-test-helper.js';
|
||||
import { PolicyDecision } from '@google/gemini-cli-core';
|
||||
|
||||
describe('Model Steering Behavioral Evals', () => {
|
||||
appEvalTest('USUALLY_PASSES', {
|
||||
appEvalTest('ALWAYS_PASSES', {
|
||||
name: 'Corrective Hint: Model switches task based on hint during tool turn',
|
||||
configOverrides: {
|
||||
excludeTools: ['run_shell_command', 'ls', 'google_web_search'],
|
||||
modelSteering: true,
|
||||
},
|
||||
files: {
|
||||
@@ -51,9 +52,10 @@ describe('Model Steering Behavioral Evals', () => {
|
||||
},
|
||||
});
|
||||
|
||||
appEvalTest('USUALLY_PASSES', {
|
||||
appEvalTest('ALWAYS_PASSES', {
|
||||
name: 'Suggestive Hint: Model incorporates user guidance mid-stream',
|
||||
configOverrides: {
|
||||
excludeTools: ['run_shell_command', 'ls', 'google_web_search'],
|
||||
modelSteering: true,
|
||||
},
|
||||
files: {},
|
||||
|
||||
@@ -16,7 +16,9 @@ describe('save_memory', () => {
|
||||
const rememberingFavoriteColor = "Agent remembers user's favorite color";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingFavoriteColor,
|
||||
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `remember that my favorite color is blue.
|
||||
|
||||
what is my favorite color? tell me that and surround it with $ symbol`,
|
||||
@@ -36,7 +38,9 @@ describe('save_memory', () => {
|
||||
const rememberingCommandRestrictions = 'Agent remembers command restrictions';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingCommandRestrictions,
|
||||
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `I don't want you to ever run npm commands.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
@@ -55,7 +59,9 @@ describe('save_memory', () => {
|
||||
const rememberingWorkflow = 'Agent remembers workflow preferences';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingWorkflow,
|
||||
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `I want you to always lint after building.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
@@ -75,7 +81,9 @@ describe('save_memory', () => {
|
||||
'Agent ignores temporary conversation details';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: ignoringTemporaryInformation,
|
||||
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `I'm going to get a coffee.`,
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
@@ -98,7 +106,9 @@ describe('save_memory', () => {
|
||||
const rememberingPetName = "Agent remembers user's pet's name";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingPetName,
|
||||
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `Please remember that my dog's name is Buddy.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
@@ -117,7 +127,9 @@ describe('save_memory', () => {
|
||||
const rememberingCommandAlias = 'Agent remembers custom command aliases';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingCommandAlias,
|
||||
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `When I say 'start server', you should run 'npm run dev'.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
@@ -137,6 +149,18 @@ describe('save_memory', () => {
|
||||
"Agent ignores workspace's database schema location";
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: ignoringDbSchemaLocation,
|
||||
params: {
|
||||
settings: {
|
||||
tools: {
|
||||
core: [
|
||||
'save_memory',
|
||||
'list_directory',
|
||||
'read_file',
|
||||
'run_shell_command',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
@@ -156,7 +180,9 @@ describe('save_memory', () => {
|
||||
"Agent remembers user's coding style preference";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingCodingStyle,
|
||||
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `I prefer to use tabs instead of spaces for indentation.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
@@ -176,6 +202,18 @@ describe('save_memory', () => {
|
||||
'Agent ignores workspace build artifact location';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: ignoringBuildArtifactLocation,
|
||||
params: {
|
||||
settings: {
|
||||
tools: {
|
||||
core: [
|
||||
'save_memory',
|
||||
'list_directory',
|
||||
'read_file',
|
||||
'run_shell_command',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
@@ -194,6 +232,18 @@ describe('save_memory', () => {
|
||||
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: ignoringMainEntryPoint,
|
||||
params: {
|
||||
settings: {
|
||||
tools: {
|
||||
core: [
|
||||
'save_memory',
|
||||
'list_directory',
|
||||
'read_file',
|
||||
'run_shell_command',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
@@ -212,7 +262,9 @@ describe('save_memory', () => {
|
||||
const rememberingBirthday = "Agent remembers user's birthday";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingBirthday,
|
||||
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `My birthday is on June 15th.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
|
||||
@@ -197,25 +197,9 @@ export function symlinkNodeModules(testDir: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings that are forbidden in evals. Evals should never restrict which
|
||||
* tools are available — they must test against the full, default tool set
|
||||
* to ensure realistic behavior.
|
||||
*/
|
||||
interface ForbiddenToolSettings {
|
||||
tools?: {
|
||||
/** Restricting core tools in evals is forbidden. */
|
||||
core?: never;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EvalCase {
|
||||
name: string;
|
||||
params?: {
|
||||
settings?: ForbiddenToolSettings & Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
params?: Record<string, any>;
|
||||
prompt: string;
|
||||
timeout?: number;
|
||||
files?: Record<string, string>;
|
||||
|
||||
@@ -16,10 +16,6 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
testTimeout: 300000, // 5 minutes
|
||||
// Retry in CI but not nightly to avoid blocking on API error.
|
||||
retry: process.env['VITEST_RETRY']
|
||||
? parseInt(process.env['VITEST_RETRY'], 10)
|
||||
: 3,
|
||||
reporters: ['default', 'json'],
|
||||
outputFile: {
|
||||
json: 'evals/logs/report.json',
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll open https://example.com and check the page title for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and get the page title"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":35,"totalTokenCount":135}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I have opened the page and the title is 'Example Domain'."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The task is complete. The page title is 'Example Domain'."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":20,"totalTokenCount":320}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Done."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":400,"candidatesTokenCount":5,"totalTokenCount":405}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The page title of https://example.com is \"Example Domain\". The browser session has been completed and cleaned up successfully."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
|
||||
|
||||
@@ -175,36 +175,4 @@ priority = 200
|
||||
expect(output).toContain('browser_agent');
|
||||
expect(output).toContain('completed successfully');
|
||||
});
|
||||
|
||||
it('should show the visible warning when browser agent starts in existing session mode', async () => {
|
||||
rig.setup('browser-session-warning', {
|
||||
fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'),
|
||||
settings: {
|
||||
general: {
|
||||
enableAutoUpdateNotification: false,
|
||||
},
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
sessionMode: 'existing',
|
||||
headless: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const stdout = await rig.runCommand(['Open https://example.com'], {
|
||||
env: {
|
||||
GEMINI_API_KEY: 'fake-key',
|
||||
GEMINI_TELEMETRY_DISABLED: 'true',
|
||||
DEV: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
expect(stdout).toContain('saved logins will be visible');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,20 +34,16 @@ describe('extension install', () => {
|
||||
writeFileSync(testServerPath, extension);
|
||||
try {
|
||||
const result = await rig.runCommand(
|
||||
['--debug', 'extensions', 'install', `${rig.testDir!}`],
|
||||
['extensions', 'install', `${rig.testDir!}`],
|
||||
{ stdin: 'y\n' },
|
||||
);
|
||||
expect(result).toContain('test-extension-install');
|
||||
|
||||
const listResult = await rig.runCommand([
|
||||
'--debug',
|
||||
'extensions',
|
||||
'list',
|
||||
]);
|
||||
const listResult = await rig.runCommand(['extensions', 'list']);
|
||||
expect(listResult).toContain('test-extension-install');
|
||||
writeFileSync(testServerPath, extensionUpdate);
|
||||
const updateResult = await rig.runCommand(
|
||||
['--debug', 'extensions', 'update', `test-extension-install`],
|
||||
['extensions', 'update', `test-extension-install`],
|
||||
{ stdin: 'y\n' },
|
||||
);
|
||||
expect(updateResult).toContain('0.0.2');
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('extension reloading', () => {
|
||||
}
|
||||
|
||||
const result = await rig.runCommand(
|
||||
['--debug', 'extensions', 'install', `${rig.testDir!}`],
|
||||
['extensions', 'install', `${rig.testDir!}`],
|
||||
{ stdin: 'y\n' },
|
||||
);
|
||||
expect(result).toContain('test-extension');
|
||||
|
||||
@@ -7,10 +7,9 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig, poll, normalizePath } from './test-helper.js';
|
||||
import { join } from 'node:path';
|
||||
import { writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
describe('Hooks System Integration', { timeout: 120000 }, () => {
|
||||
describe('Hooks System Integration', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -2017,10 +2016,6 @@ console.log(JSON.stringify({
|
||||
|
||||
// 3. Final setup with full settings
|
||||
rig.setup('Hook Disabling Multiple Ops', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.disabled-via-command.responses',
|
||||
),
|
||||
settings: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
@@ -2235,7 +2230,7 @@ console.log(JSON.stringify({
|
||||
|
||||
// The hook should have stopped execution message (returned from tool)
|
||||
expect(result).toContain(
|
||||
'Agent execution stopped by hook: Emergency Stop triggered by hook',
|
||||
'Agent execution stopped: Emergency Stop triggered by hook',
|
||||
);
|
||||
|
||||
// Tool should NOT be called successfully (it was blocked/stopped)
|
||||
@@ -2247,210 +2242,4 @@ console.log(JSON.stringify({
|
||||
expect(writeFileCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hooks "ask" Decision Integration', () => {
|
||||
it(
|
||||
'should force confirmation prompt when hook returns "ask" decision even in YOLO mode',
|
||||
{ timeout: 60000 },
|
||||
async () => {
|
||||
const testName =
|
||||
'should force confirmation prompt when hook returns "ask" decision even in YOLO mode';
|
||||
|
||||
// 1. Setup hook script that returns 'ask' decision
|
||||
const hookOutput = {
|
||||
decision: 'ask',
|
||||
systemMessage: 'Confirmation forced by security hook',
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'BeforeTool',
|
||||
},
|
||||
};
|
||||
|
||||
const hookScript = `console.log(JSON.stringify(${JSON.stringify(
|
||||
hookOutput,
|
||||
)}));`;
|
||||
|
||||
// Create script path predictably
|
||||
const scriptPath = join(os.tmpdir(), 'gemini-cli-tests-ask-hook.js');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
// 2. Setup rig with YOLO mode enabled but with the 'ask' hook
|
||||
rig.setup(testName, {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.allow-tool.responses',
|
||||
),
|
||||
settings: {
|
||||
debugMode: true,
|
||||
tools: {
|
||||
approval: 'yolo',
|
||||
},
|
||||
general: {
|
||||
enableAutoUpdateNotification: false,
|
||||
},
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${scriptPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Bypass terminal setup prompt and other startup banners
|
||||
const stateDir = join(rig.homeDir!, '.gemini');
|
||||
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(stateDir, 'state.json'),
|
||||
JSON.stringify({
|
||||
terminalSetupPromptShown: true,
|
||||
hasSeenScreenReaderNudge: true,
|
||||
tipsShown: 100,
|
||||
}),
|
||||
);
|
||||
|
||||
// 3. Run interactive and verify prompt appears despite YOLO mode
|
||||
const run = await rig.runInteractive();
|
||||
|
||||
// Wait for prompt to appear
|
||||
await run.expectText('Type your message', 30000);
|
||||
|
||||
// Send prompt that will trigger write_file
|
||||
await run.type('Create a file called ask-test.txt with content "test"');
|
||||
await run.type('\r');
|
||||
|
||||
// Wait for the FORCED confirmation prompt to appear
|
||||
// It should contain the system message from the hook
|
||||
await run.expectText('Confirmation forced by security hook', 30000);
|
||||
await run.expectText('Allow', 5000);
|
||||
|
||||
// 4. Approve the permission
|
||||
await run.type('y');
|
||||
await run.type('\r');
|
||||
|
||||
// Wait for command to execute
|
||||
await run.expectText('approved.txt', 30000);
|
||||
|
||||
// Should find the tool call
|
||||
const foundWriteFile = await rig.waitForToolCall('write_file');
|
||||
expect(foundWriteFile).toBeTruthy();
|
||||
|
||||
// File should be created
|
||||
const fileContent = rig.readFile('approved.txt');
|
||||
expect(fileContent).toBe('Approved content');
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
'should allow cancelling when hook forces "ask" decision',
|
||||
{ timeout: 60000 },
|
||||
async () => {
|
||||
const testName =
|
||||
'should allow cancelling when hook forces "ask" decision';
|
||||
const hookOutput = {
|
||||
decision: 'ask',
|
||||
systemMessage: 'Confirmation forced for cancellation test',
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'BeforeTool',
|
||||
},
|
||||
};
|
||||
|
||||
const hookScript = `console.log(JSON.stringify(${JSON.stringify(
|
||||
hookOutput,
|
||||
)}));`;
|
||||
|
||||
const scriptPath = join(
|
||||
os.tmpdir(),
|
||||
'gemini-cli-tests-ask-cancel-hook.js',
|
||||
);
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
rig.setup(testName, {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.allow-tool.responses',
|
||||
),
|
||||
settings: {
|
||||
debugMode: true,
|
||||
tools: {
|
||||
approval: 'yolo',
|
||||
},
|
||||
general: {
|
||||
enableAutoUpdateNotification: false,
|
||||
},
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${scriptPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Bypass terminal setup prompt and other startup banners
|
||||
const stateDir = join(rig.homeDir!, '.gemini');
|
||||
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(stateDir, 'state.json'),
|
||||
JSON.stringify({
|
||||
terminalSetupPromptShown: true,
|
||||
hasSeenScreenReaderNudge: true,
|
||||
tipsShown: 100,
|
||||
}),
|
||||
);
|
||||
|
||||
const run = await rig.runInteractive();
|
||||
|
||||
// Wait for prompt to appear
|
||||
await run.expectText('Type your message', 30000);
|
||||
|
||||
await run.type(
|
||||
'Create a file called cancel-test.txt with content "test"',
|
||||
);
|
||||
await run.type('\r');
|
||||
|
||||
await run.expectText(
|
||||
'Confirmation forced for cancellation test',
|
||||
30000,
|
||||
);
|
||||
|
||||
// 4. Deny the permission using option 4
|
||||
await run.type('4');
|
||||
await run.type('\r');
|
||||
|
||||
// Wait for cancellation message
|
||||
await run.expectText('Cancelled', 15000);
|
||||
|
||||
// Tool should NOT be called successfully
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const writeFileCalls = toolLogs.filter(
|
||||
(t) =>
|
||||
t.toolRequest.name === 'write_file' &&
|
||||
t.toolRequest.success === true,
|
||||
);
|
||||
expect(writeFileCalls).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && cross-env GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
|
||||
"lint": "eslint . --cache --max-warnings 0",
|
||||
"lint": "eslint . --cache",
|
||||
"lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format",
|
||||
"lint:ci": "npm run lint:all",
|
||||
"lint:all": "node scripts/lint.js",
|
||||
|
||||
@@ -54,7 +54,6 @@ export async function getMcpServersFromConfig(
|
||||
return;
|
||||
}
|
||||
mcpServers[key] = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...server,
|
||||
extension,
|
||||
};
|
||||
|
||||
@@ -1716,7 +1716,6 @@ describe('loadCliConfig with admin.mcp.config', () => {
|
||||
|
||||
const serverA = config.getMcpServers()?.['serverA'];
|
||||
expect(serverA).toEqual({
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...localMcpServers['serverA'],
|
||||
type: 'sse',
|
||||
url: 'https://admin-server-a.com/sse',
|
||||
@@ -1767,7 +1766,6 @@ describe('loadCliConfig with admin.mcp.config', () => {
|
||||
};
|
||||
const localMcpServersWithTools: Record<string, MCPServerConfig> = {
|
||||
serverA: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...localMcpServers['serverA'],
|
||||
includeTools: ['local_tool'],
|
||||
timeout: 1234,
|
||||
@@ -1810,7 +1808,6 @@ describe('loadCliConfig with admin.mcp.config', () => {
|
||||
};
|
||||
const localMcpServersWithTools: Record<string, MCPServerConfig> = {
|
||||
serverA: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...localMcpServers['serverA'],
|
||||
includeTools: ['local_tool'],
|
||||
},
|
||||
|
||||
@@ -637,4 +637,64 @@ describe('ExtensionManager', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('orphaned extension cleanup', () => {
|
||||
it('should remove broken extension metadata on startup to allow re-installation', async () => {
|
||||
const extName = 'orphaned-ext';
|
||||
const sourceDir = path.join(tempHomeDir, 'valid-source');
|
||||
fs.mkdirSync(sourceDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(sourceDir, 'gemini-extension.json'),
|
||||
JSON.stringify({ name: extName, version: '1.0.0' }),
|
||||
);
|
||||
|
||||
// Link an extension successfully.
|
||||
await extensionManager.loadExtensions();
|
||||
await extensionManager.installOrUpdateExtension({
|
||||
source: sourceDir,
|
||||
type: 'link',
|
||||
});
|
||||
|
||||
const destinationPath = path.join(userExtensionsDir, extName);
|
||||
const metadataPath = path.join(
|
||||
destinationPath,
|
||||
'.gemini-extension-install.json',
|
||||
);
|
||||
expect(fs.existsSync(metadataPath)).toBe(true);
|
||||
|
||||
// Simulate metadata corruption (e.g., pointing to a non-existent source).
|
||||
fs.writeFileSync(
|
||||
metadataPath,
|
||||
JSON.stringify({ source: '/NON_EXISTENT_PATH', type: 'link' }),
|
||||
);
|
||||
|
||||
// Simulate CLI startup. The manager should detect the broken link
|
||||
// and proactively delete the orphaned metadata directory.
|
||||
const newManager = new ExtensionManager({
|
||||
settings: createTestMergedSettings(),
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: null,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
|
||||
await newManager.loadExtensions();
|
||||
|
||||
// Verify the extension failed to load and was proactively cleaned up.
|
||||
expect(newManager.getExtensions().some((e) => e.name === extName)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(fs.existsSync(destinationPath)).toBe(false);
|
||||
|
||||
// Verify the system is self-healed and allows re-linking to the valid source.
|
||||
await newManager.installOrUpdateExtension({
|
||||
source: sourceDir,
|
||||
type: 'link',
|
||||
});
|
||||
|
||||
expect(newManager.getExtensions().some((e) => e.name === extName)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -982,11 +982,18 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
plan: config.plan,
|
||||
};
|
||||
} catch (e) {
|
||||
debugLogger.error(
|
||||
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
|
||||
e,
|
||||
)}`,
|
||||
const extName = path.basename(extensionDir);
|
||||
debugLogger.warn(
|
||||
`Warning: Removing broken extension ${extName}: ${getErrorMessage(e)}`,
|
||||
);
|
||||
try {
|
||||
await fs.promises.rm(extensionDir, { recursive: true, force: true });
|
||||
} catch (rmError) {
|
||||
debugLogger.error(
|
||||
`Failed to remove broken extension directory ${extensionDir}:`,
|
||||
rmError,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,10 +249,8 @@ describe('extension tests', () => {
|
||||
expect(extensions[0].name).toBe('test-extension');
|
||||
});
|
||||
|
||||
it('should skip the extension if a context file path is outside the extension directory and log an error', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
it('should log a warning and remove the extension if a context file path is outside the extension directory', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'traversal-extension',
|
||||
@@ -662,10 +660,8 @@ name = "yolo-checker"
|
||||
expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}');
|
||||
});
|
||||
|
||||
it('should skip an extension with invalid JSON config and log an error', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
it('should remove an extension with invalid JSON config and log a warning', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
// Good extension
|
||||
createExtension({
|
||||
@@ -686,17 +682,15 @@ name = "yolo-checker"
|
||||
expect(extensions[0].name).toBe('good-ext');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`,
|
||||
`Warning: Removing broken extension bad-ext: Failed to load extension config from ${badConfigPath}`,
|
||||
),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should skip an extension with missing "name" in config and log an error', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
it('should remove an extension with missing "name" in config and log a warning', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
// Good extension
|
||||
createExtension({
|
||||
@@ -717,7 +711,7 @@ name = "yolo-checker"
|
||||
expect(extensions[0].name).toBe('good-ext');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
|
||||
`Warning: Removing broken extension bad-ext-no-name: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -743,10 +737,8 @@ name = "yolo-checker"
|
||||
expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should log an error for invalid extension names during loading', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
it('should log a warning for invalid extension names during loading', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'bad_name',
|
||||
|
||||
@@ -13,7 +13,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
Storage: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...actual.Storage,
|
||||
getGlobalGeminiDir: () => '/virtual-home/.gemini',
|
||||
},
|
||||
|
||||
@@ -1198,26 +1198,6 @@ const SETTINGS_SCHEMA = {
|
||||
'Disable user input on browser window during automation.',
|
||||
showInDialog: false,
|
||||
},
|
||||
confirmSensitiveActions: {
|
||||
type: 'boolean',
|
||||
label: 'Confirm Sensitive Actions',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Require manual confirmation for sensitive browser actions (e.g., fill_form, evaluate_script).',
|
||||
showInDialog: true,
|
||||
},
|
||||
blockFileUploads: {
|
||||
type: 'boolean',
|
||||
label: 'Block File Uploads',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Hard-block file upload requests from the browser agent.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -126,7 +126,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
clearInstance: vi.fn(),
|
||||
},
|
||||
coreEvents: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...actual.coreEvents,
|
||||
emitFeedback: vi.fn(),
|
||||
emitConsoleLog: vi.fn(),
|
||||
@@ -1509,7 +1508,6 @@ describe('startInteractiveUI', () => {
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
const mockConfigWithScreenReader = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockConfig,
|
||||
getScreenReader: () => screenReader,
|
||||
} as Config;
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
ValidationRequiredError,
|
||||
type AdminControlsSettings,
|
||||
debugLogger,
|
||||
isHeadlessMode,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
@@ -214,36 +213,12 @@ export async function main() {
|
||||
loadSettingsHandle?.end();
|
||||
|
||||
// If a worktree is requested and enabled, set it up early.
|
||||
// This must be awaited before any other async tasks that depend on CWD (like loadCliConfig)
|
||||
// because setupWorktree calls process.chdir().
|
||||
const requestedWorktree = cliConfig.getRequestedWorktreeName(settings);
|
||||
let worktreeInfo: WorktreeInfo | undefined;
|
||||
if (requestedWorktree !== undefined) {
|
||||
const worktreeHandle = startupProfiler.start('setup_worktree');
|
||||
worktreeInfo = await setupWorktree(requestedWorktree || undefined);
|
||||
worktreeHandle?.end();
|
||||
}
|
||||
|
||||
const cleanupOpsHandle = startupProfiler.start('cleanup_ops');
|
||||
Promise.all([
|
||||
cleanupCheckpoints(),
|
||||
cleanupToolOutputFiles(settings.merged),
|
||||
cleanupBackgroundLogs(),
|
||||
])
|
||||
.catch((e) => {
|
||||
debugLogger.error('Early cleanup failed:', e);
|
||||
})
|
||||
.finally(() => {
|
||||
cleanupOpsHandle?.end();
|
||||
});
|
||||
|
||||
const parseArgsHandle = startupProfiler.start('parse_arguments');
|
||||
const argvPromise = parseArguments(settings.merged).finally(() => {
|
||||
parseArgsHandle?.end();
|
||||
});
|
||||
|
||||
const rawStartupWarningsPromise = getStartupWarnings();
|
||||
|
||||
// Report settings errors once during startup
|
||||
settings.errors.forEach((error) => {
|
||||
coreEvents.emitFeedback('warning', error.message);
|
||||
@@ -257,7 +232,15 @@ export async function main() {
|
||||
);
|
||||
});
|
||||
|
||||
const argv = await argvPromise;
|
||||
await Promise.all([
|
||||
cleanupCheckpoints(),
|
||||
cleanupToolOutputFiles(settings.merged),
|
||||
cleanupBackgroundLogs(),
|
||||
]);
|
||||
|
||||
const parseArgsHandle = startupProfiler.start('parse_arguments');
|
||||
const argv = await parseArguments(settings.merged);
|
||||
parseArgsHandle?.end();
|
||||
|
||||
if (
|
||||
(argv.allowedTools && argv.allowedTools.length > 0) ||
|
||||
@@ -297,7 +280,6 @@ export async function main() {
|
||||
const isDebugMode = cliConfig.isDebugMode(argv);
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: true,
|
||||
interactive: isHeadlessMode() ? false : true,
|
||||
debugMode: isDebugMode,
|
||||
onNewMessage: (msg) => {
|
||||
coreEvents.emitConsoleLog(msg.type, msg.content);
|
||||
@@ -485,10 +467,12 @@ export async function main() {
|
||||
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
|
||||
});
|
||||
|
||||
// Launch cleanup expired sessions as a background task
|
||||
cleanupExpiredSessions(config, settings.merged).catch((e) => {
|
||||
// Cleanup sessions after config initialization
|
||||
try {
|
||||
await cleanupExpiredSessions(config, settings.merged);
|
||||
} catch (e) {
|
||||
debugLogger.error('Failed to cleanup expired sessions:', e);
|
||||
});
|
||||
}
|
||||
|
||||
if (config.getListExtensions()) {
|
||||
debugLogger.log('Installed extensions:');
|
||||
@@ -540,9 +524,7 @@ export async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
const terminalHandle = startupProfiler.start('setup_terminal');
|
||||
await setupTerminalAndTheme(config, settings);
|
||||
terminalHandle?.end();
|
||||
|
||||
const initAppHandle = startupProfiler.start('initialize_app');
|
||||
const initializationResult = await initializeApp(config, settings);
|
||||
@@ -566,7 +548,7 @@ export async function main() {
|
||||
isAlternateBufferEnabled(config),
|
||||
config.getScreenReader(),
|
||||
);
|
||||
const rawStartupWarnings = await rawStartupWarningsPromise;
|
||||
const rawStartupWarnings = await getStartupWarnings();
|
||||
const startupWarnings: StartupWarning[] = [
|
||||
...rawStartupWarnings.map((message) => ({
|
||||
id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`,
|
||||
|
||||
@@ -1137,7 +1137,6 @@ describe('runNonInteractive', () => {
|
||||
|
||||
expect(
|
||||
processStderrSpy.mock.calls.some(
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
(call) => typeof call[0] === 'string' && call[0].includes('Cancelling'),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
@@ -65,7 +65,6 @@ export async function runNonInteractive({
|
||||
return promptIdContext.run(prompt_id, async () => {
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: true,
|
||||
interactive: false,
|
||||
debugMode: config.getDebugMode(),
|
||||
onNewMessage: (msg) => {
|
||||
coreEvents.emitConsoleLog(msg.type, msg.content);
|
||||
|
||||
@@ -266,7 +266,6 @@ describe('BuiltinCommandLoader', () => {
|
||||
|
||||
it('should include policies command when message bus integration is enabled', async () => {
|
||||
const mockConfigWithMessageBus = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockConfig,
|
||||
getEnableHooks: () => false,
|
||||
getMcpEnabled: () => true,
|
||||
|
||||
@@ -11,11 +11,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { AppContainer } from '../ui/AppContainer.js';
|
||||
import {
|
||||
renderWithProviders,
|
||||
type RenderInstance,
|
||||
persistentStateMock,
|
||||
} from './render.js';
|
||||
import { renderWithProviders, type RenderInstance } from './render.js';
|
||||
import {
|
||||
makeFakeConfig,
|
||||
type Config,
|
||||
@@ -184,11 +180,6 @@ export class AppRig {
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
persistentStateMock.setData({
|
||||
terminalSetupPromptShown: true,
|
||||
tipsShown: 10,
|
||||
});
|
||||
|
||||
this.setupEnvironment();
|
||||
resetSettingsCacheForTesting();
|
||||
this.settings = this.createRigSettings();
|
||||
@@ -235,8 +226,6 @@ export class AppRig {
|
||||
private setupEnvironment() {
|
||||
// Stub environment variables to avoid interference from developer's machine
|
||||
vi.stubEnv('GEMINI_CLI_HOME', this.testDir);
|
||||
vi.stubEnv('TERM_PROGRAM', 'other');
|
||||
vi.stubEnv('VSCODE_GIT_IPC_HANDLE', '');
|
||||
if (this.options.fakeResponsesPath) {
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
MockShellExecutionService.setPassthrough(false);
|
||||
@@ -302,6 +291,7 @@ export class AppRig {
|
||||
|
||||
const newContentGeneratorConfig = {
|
||||
authType: authMethod,
|
||||
|
||||
proxy: gcConfig.getProxy(),
|
||||
apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key',
|
||||
};
|
||||
|
||||
@@ -79,7 +79,7 @@ export async function toMatchSvgSnapshot(
|
||||
}
|
||||
|
||||
function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
const { isNot } = this as any;
|
||||
let pass = true;
|
||||
const invalidLines: Array<{ line: number; content: string }> = [];
|
||||
@@ -108,6 +108,7 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
expect.extend({
|
||||
toHaveOnlyValidCharacters,
|
||||
toMatchSvgSnapshot,
|
||||
|
||||
@@ -37,12 +37,14 @@ export const createMockCommandContext = (
|
||||
},
|
||||
services: {
|
||||
agentContext: null,
|
||||
|
||||
settings: {
|
||||
merged: defaultMergedSettings,
|
||||
setValue: vi.fn(),
|
||||
forScope: vi.fn().mockReturnValue({ settings: {} }),
|
||||
} as unknown as LoadedSettings,
|
||||
git: undefined as GitService | undefined,
|
||||
|
||||
logger: {
|
||||
log: vi.fn(),
|
||||
logMessage: vi.fn(),
|
||||
@@ -51,6 +53,7 @@ export const createMockCommandContext = (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any, // Cast because Logger is a class.
|
||||
},
|
||||
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
@@ -69,6 +72,7 @@ export const createMockCommandContext = (
|
||||
} as any,
|
||||
session: {
|
||||
sessionShellAllowlist: new Set<string>(),
|
||||
|
||||
stats: {
|
||||
sessionStartTime: new Date(),
|
||||
lastPromptTokenCount: 0,
|
||||
@@ -94,6 +98,7 @@ export const createMockCommandContext = (
|
||||
for (const key in source) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
const sourceValue = source[key];
|
||||
|
||||
const targetValue = output[key];
|
||||
|
||||
if (
|
||||
@@ -104,6 +109,7 @@ export const createMockCommandContext = (
|
||||
output[key] = merge(targetValue, sourceValue);
|
||||
} else {
|
||||
// If not, we do a direct assignment. This preserves Date objects and others.
|
||||
|
||||
output[key] = sourceValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,14 +376,6 @@ export type RenderInstance = {
|
||||
capturedOverflowActions: OverflowActions | undefined;
|
||||
};
|
||||
|
||||
export type RenderWithProvidersInstance = RenderInstance & {
|
||||
simulateClick: (
|
||||
col: number,
|
||||
row: number,
|
||||
button?: 0 | 1 | 2,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
const instances: InkInstance[] = [];
|
||||
|
||||
export const render = async (
|
||||
@@ -626,7 +618,15 @@ export const renderWithProviders = async (
|
||||
};
|
||||
appState?: AppState;
|
||||
} = {},
|
||||
): Promise<RenderWithProvidersInstance> => {
|
||||
): Promise<
|
||||
RenderInstance & {
|
||||
simulateClick: (
|
||||
col: number,
|
||||
row: number,
|
||||
button?: 0 | 1 | 2,
|
||||
) => Promise<void>;
|
||||
}
|
||||
> => {
|
||||
const baseState: UIState = new Proxy(
|
||||
{ ...baseMockUiState, ...providedUiState },
|
||||
{
|
||||
@@ -778,6 +778,7 @@ export async function renderHook<Result, Props>(
|
||||
generateSvg: () => string;
|
||||
}> {
|
||||
const result = { current: undefined as unknown as Result };
|
||||
|
||||
let currentProps = options?.initialProps as Props;
|
||||
|
||||
function TestComponent({
|
||||
@@ -860,7 +861,13 @@ export async function renderHookWithProviders<Result, Props>(
|
||||
|
||||
const Wrapper = options.wrapper || (({ children }) => <>{children}</>);
|
||||
|
||||
let renderResult: RenderWithProvidersInstance;
|
||||
let renderResult: RenderInstance & {
|
||||
simulateClick: (
|
||||
col: number,
|
||||
row: number,
|
||||
button?: 0 | 1 | 2,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
renderResult = await renderWithProviders(
|
||||
|
||||
@@ -46,6 +46,7 @@ export const createMockSettings = (
|
||||
workspace,
|
||||
isTrusted,
|
||||
errors,
|
||||
|
||||
merged: mergedOverride,
|
||||
...settingsOverrides
|
||||
} = overrides;
|
||||
@@ -60,6 +61,7 @@ export const createMockSettings = (
|
||||
settings: settingsOverrides,
|
||||
originalSettings: settingsOverrides,
|
||||
},
|
||||
|
||||
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
|
||||
isTrusted ?? true,
|
||||
errors || [],
|
||||
|
||||
@@ -42,7 +42,6 @@ describe('IdeIntegrationNudge', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(debugLogger.warn).mockImplementation((...args) => {
|
||||
if (
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof args[0] === 'string' &&
|
||||
/was not wrapped in act/.test(args[0])
|
||||
) {
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
|
||||
exports[`App > Snapshots > renders default layout correctly 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.2.3
|
||||
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -34,6 +31,9 @@ Tips for getting started:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -47,13 +47,10 @@ exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
|
||||
"Notifications
|
||||
Footer
|
||||
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.2.3
|
||||
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -67,12 +64,12 @@ Composer
|
||||
|
||||
exports[`App > Snapshots > renders with dialogs visible 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Gemini CLI v1.2.3
|
||||
|
||||
|
||||
|
||||
@@ -110,13 +107,10 @@ DialogManager
|
||||
|
||||
exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.2.3
|
||||
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -146,6 +140,9 @@ HistoryItemDisplay
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Notifications
|
||||
Composer
|
||||
"
|
||||
|
||||
@@ -42,7 +42,6 @@ describe('AuthInProgress', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(debugLogger.error).mockImplementation((...args) => {
|
||||
if (
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof args[0] === 'string' &&
|
||||
args[0].includes('was not wrapped in act')
|
||||
) {
|
||||
|
||||
@@ -38,7 +38,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...actual.coreEvents,
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from '../../test-utils/render.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
vi.mock('../utils/terminalSetup.js', () => ({
|
||||
@@ -241,27 +240,4 @@ describe('<AppHeader />', () => {
|
||||
expect(session2.lastFrame()).not.toContain('Tips');
|
||||
session2.unmount();
|
||||
});
|
||||
|
||||
it('should render the full logo when logged out', async () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: undefined,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState: {
|
||||
terminalWidth: 120,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Check for block characters from the logo
|
||||
expect(lastFrame()).toContain('▗█▀▀▜▙');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,9 +19,6 @@ import { CliSpinner } from './CliSpinner.js';
|
||||
|
||||
import { isAppleTerminal } from '@google/gemini-cli-core';
|
||||
|
||||
import { longAsciiLogoCompactText } from './AsciiArt.js';
|
||||
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
||||
|
||||
interface AppHeaderProps {
|
||||
version: string;
|
||||
showDetails?: boolean;
|
||||
@@ -44,18 +41,6 @@ const MAC_TERMINAL_ICON = `▝▜▄
|
||||
▗▟▀
|
||||
▗▟▀ `;
|
||||
|
||||
/**
|
||||
* The horizontal padding (in columns) required for metadata (version, identity, etc.)
|
||||
* when rendered alongside the ASCII logo.
|
||||
*/
|
||||
const LOGO_METADATA_PADDING = 20;
|
||||
|
||||
/**
|
||||
* The terminal width below which we switch to a narrow/column layout to prevent
|
||||
* UI elements from wrapping or overlapping.
|
||||
*/
|
||||
const NARROW_TERMINAL_BREAKPOINT = 60;
|
||||
|
||||
export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
@@ -64,90 +49,70 @@ export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
|
||||
const { bannerText } = useBanner(bannerData);
|
||||
const { showTips } = useTips();
|
||||
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
const loggedOut = !authType;
|
||||
|
||||
const showHeader = !(
|
||||
settings.merged.ui.hideBanner || config.getScreenReader()
|
||||
);
|
||||
|
||||
const ICON = isAppleTerminal() ? MAC_TERMINAL_ICON : DEFAULT_ICON;
|
||||
|
||||
let logoTextArt = '';
|
||||
if (loggedOut) {
|
||||
const widthOfLongLogo =
|
||||
getAsciiArtWidth(longAsciiLogoCompactText) + LOGO_METADATA_PADDING;
|
||||
|
||||
if (terminalWidth >= widthOfLongLogo) {
|
||||
logoTextArt = longAsciiLogoCompactText.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// If the terminal is too narrow to fit the icon and metadata (especially long nightly versions)
|
||||
// side-by-side, we switch to column mode to prevent wrapping.
|
||||
const isNarrow = terminalWidth < NARROW_TERMINAL_BREAKPOINT;
|
||||
|
||||
const renderLogo = () => (
|
||||
<Box flexDirection="row">
|
||||
<Box flexShrink={0}>
|
||||
<ThemedGradient>{ICON}</ThemedGradient>
|
||||
</Box>
|
||||
{logoTextArt && (
|
||||
<Box marginLeft={3}>
|
||||
<Text color={theme.text.primary}>{logoTextArt}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderMetadata = (isBelow = false) => (
|
||||
<Box marginLeft={isBelow ? 0 : 2} flexDirection="column">
|
||||
{/* Line 1: Gemini CLI vVersion [Updating] */}
|
||||
<Box>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Gemini CLI
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> v{version}</Text>
|
||||
{updateInfo && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
<CliSpinner /> Updating
|
||||
</Text>
|
||||
if (!showDetails) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{showHeader && (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
marginTop={1}
|
||||
marginBottom={1}
|
||||
paddingLeft={2}
|
||||
>
|
||||
<Box flexShrink={0}>
|
||||
<ThemedGradient>{ICON}</ThemedGradient>
|
||||
</Box>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<Box>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Gemini CLI
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> v{version}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{showDetails && (
|
||||
<>
|
||||
{/* Line 2: Blank */}
|
||||
<Box height={1} />
|
||||
|
||||
{/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */}
|
||||
{settings.merged.ui.showUserIdentity !== false && (
|
||||
<UserIdentity config={config} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const useColumnLayout = !!logoTextArt || isNarrow;
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{showHeader && (
|
||||
<Box
|
||||
flexDirection={useColumnLayout ? 'column' : 'row'}
|
||||
marginTop={1}
|
||||
marginBottom={1}
|
||||
paddingLeft={1}
|
||||
>
|
||||
{renderLogo()}
|
||||
{useColumnLayout ? (
|
||||
<Box marginTop={1}>{renderMetadata(true)}</Box>
|
||||
) : (
|
||||
renderMetadata(false)
|
||||
)}
|
||||
<Box flexDirection="row" marginTop={1} marginBottom={1} paddingLeft={2}>
|
||||
<Box flexShrink={0}>
|
||||
<ThemedGradient>{ICON}</ThemedGradient>
|
||||
</Box>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
{/* Line 1: Gemini CLI vVersion [Updating] */}
|
||||
<Box>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Gemini CLI
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> v{version}</Text>
|
||||
{updateInfo && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
<CliSpinner /> Updating
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Line 2: Blank */}
|
||||
<Box height={1} />
|
||||
|
||||
{/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */}
|
||||
{settings.merged.ui.showUserIdentity !== false && (
|
||||
<UserIdentity config={config} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,14 +16,14 @@ export const shortAsciiLogo = `
|
||||
`;
|
||||
|
||||
export const longAsciiLogo = `
|
||||
█████████ ██████████ ██████ ██████ █████ ██████ █████ █████
|
||||
███░░░░░███░░███░░░░░█░░██████ █████ ░░███░░██████ ░░███ ░░███
|
||||
███ ░░░░░░░ ░███ █ ░ ░███░█████░███ ░███ ░███░███ ░███ ░███
|
||||
░███ ░██████ ░███░░███ ░███ ░███ ░███░░███░███ ░███
|
||||
░███ █████ ░███░░█ ░███ ░░░ ░███ ░███ ░███ ░░██████ ░███
|
||||
░░███ ░░███ ░███ ░ █ ░███ ░███ ░███ ░███ ░░█████ ░███
|
||||
░░█████████ ██████████ █████ █████ █████ █████ ░░████ █████
|
||||
░░░░░░░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░ ░░░░░
|
||||
███ █████████ ██████████ ██████ ██████ █████ ██████ █████ █████
|
||||
░░░███ ███░░░░░███░░███░░░░░█░░██████ ██████ ░░███ ░░██████ ░░███ ░░███
|
||||
░░░███ ███ ░░░ ░███ █ ░ ░███░█████░███ ░███ ░███░███ ░███ ░███
|
||||
░░░███ ░███ ░██████ ░███░░███ ░███ ░███ ░███░░███░███ ░███
|
||||
███░ ░███ █████ ░███░░█ ░███ ░░░ ░███ ░███ ░███ ░░██████ ░███
|
||||
███░ ░░███ ░░███ ░███ ░ █ ░███ ░███ ░███ ░███ ░░█████ ░███
|
||||
███░ ░░█████████ ██████████ █████ █████ █████ █████ ░░█████ █████
|
||||
░░░ ░░░░░░░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░
|
||||
`;
|
||||
|
||||
export const tinyAsciiLogo = `
|
||||
@@ -36,24 +36,3 @@ export const tinyAsciiLogo = `
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
`;
|
||||
|
||||
export const shortAsciiLogoCompactText = `
|
||||
▟▛▀▀█▖▜█▀▀▜▝██▙▗██▛▝█▛▝██▙ ▜█▘▜█▘
|
||||
▐█ ▐█▄▌ █▌▜█▘█▌ █▌ █▌▜▙▐█ ▐█
|
||||
▝█▖ ▜█▘▐█ ▘▗ █▌ █▌ █▌ █▌ ▜██ ▐█
|
||||
▝▀▀▀▀ ▀▀▀▀▀▝▀▀ ▝▀▀▝▀▀▝▀▀ ▀▀▘▀▀▘
|
||||
`;
|
||||
|
||||
export const longAsciiLogoCompactText = `
|
||||
▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
`;
|
||||
|
||||
export const tinyAsciiLogoCompactText = `
|
||||
▟▛▀▀█▖
|
||||
▐█
|
||||
▝█▖ ▜█▘
|
||||
▝▀▀▀▀
|
||||
`;
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as SessionContext from '../contexts/SessionContext.js';
|
||||
import { type SessionStatsState } from '../contexts/SessionContext.js';
|
||||
import { Banner } from './Banner.js';
|
||||
import { Footer } from './Footer.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { Header } from './Header.js';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
import { StatsDisplay } from './StatsDisplay.js';
|
||||
|
||||
@@ -71,9 +71,9 @@ useSessionStatsMock.mockReturnValue({
|
||||
});
|
||||
|
||||
describe('Gradient Crash Regression Tests', () => {
|
||||
it('<AppHeader /> should not crash when theme.ui.gradient is empty', async () => {
|
||||
it('<Header /> should not crash when theme.ui.gradient is empty', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
<Header version="1.0.0" nightly={false} />,
|
||||
{
|
||||
width: 120,
|
||||
},
|
||||
|
||||
@@ -244,11 +244,6 @@ export const HooksDialog: React.FC<HooksDialogProps> = ({
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
(Press Esc to close)
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -163,7 +163,6 @@ describe('ToolConfirmationQueue', () => {
|
||||
</Box>,
|
||||
{
|
||||
config: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockConfig,
|
||||
getUseAlternateBuffer: () => true,
|
||||
} as unknown as Config,
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with a tool awaiting confirmation > with_confirming_tool 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v0.10.0
|
||||
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -25,13 +22,10 @@ Action Required (was prompted):
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages > with_history_and_pending 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v0.10.0
|
||||
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -56,13 +50,10 @@ Tips for getting started:
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v0.10.0
|
||||
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -75,13 +66,10 @@ Tips for getting started:
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with history but no pending items > with_history_no_pending 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v0.10.0
|
||||
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -102,13 +90,10 @@ Tips for getting started:
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v0.10.0
|
||||
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -125,13 +110,10 @@ Tips for getting started:
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with user and gemini messages > with_user_gemini_messages 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v0.10.0
|
||||
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
|
||||
exports[`<AppHeader /> > should not render the banner when no flags are set 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.0.0
|
||||
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -21,13 +18,10 @@ Tips for getting started:
|
||||
|
||||
exports[`<AppHeader /> > should not render the default banner if shown count is 5 or more 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.0.0
|
||||
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -40,13 +34,10 @@ Tips for getting started:
|
||||
|
||||
exports[`<AppHeader /> > should render the banner with default text 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.0.0
|
||||
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ This is the default banner │
|
||||
@@ -62,13 +53,10 @@ Tips for getting started:
|
||||
|
||||
exports[`<AppHeader /> > should render the banner with warning text 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.0.0
|
||||
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ There are capacity issues │
|
||||
@@ -81,14 +69,3 @@ Tips for getting started:
|
||||
4. Be specific for the best results
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should render the full logo when logged out 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.0.0
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -1,34 +1,30 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="275" viewBox="0 0 920 275">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="224" viewBox="0 0 920 224">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="275" fill="#000000" />
|
||||
<rect width="920" height="224" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="19" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛</text>
|
||||
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="36" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌</text>
|
||||
<text x="18" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="90" y="53" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌</text>
|
||||
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="90" y="70" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs"> ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀</text>
|
||||
<text x="9" y="104" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="99" y="104" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.0.0</text>
|
||||
<text x="0" y="172" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
|
||||
<text x="90" y="189" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
|
||||
<text x="171" y="189" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
|
||||
<text x="0" y="206" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
|
||||
<text x="27" y="206" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
|
||||
<text x="72" y="206" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="223" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="240" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="180" y="19" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.0.0</text>
|
||||
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="0" y="121" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
|
||||
<text x="0" y="138" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
|
||||
<text x="90" y="138" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
|
||||
<text x="171" y="138" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
|
||||
<text x="0" y="155" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
|
||||
<text x="27" y="155" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
|
||||
<text x="72" y="155" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="172" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -1,35 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="275" viewBox="0 0 920 275">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="224" viewBox="0 0 920 224">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="275" fill="#000000" />
|
||||
<rect width="920" height="224" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="81" y="19" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛</text>
|
||||
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="81" y="36" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌</text>
|
||||
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="45" y="53" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="81" y="53" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌</text>
|
||||
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="18" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="27" y="70" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="81" y="70" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs"> ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀</text>
|
||||
<text x="9" y="104" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="99" y="104" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.0.0</text>
|
||||
<text x="0" y="172" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
|
||||
<text x="90" y="189" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
|
||||
<text x="171" y="189" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
|
||||
<text x="0" y="206" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
|
||||
<text x="27" y="206" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
|
||||
<text x="72" y="206" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="223" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="240" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="81" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="171" y="19" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.0.0</text>
|
||||
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="54" y="53" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="36" y="70" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="0" y="121" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
|
||||
<text x="0" y="138" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
|
||||
<text x="90" y="138" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
|
||||
<text x="171" y="138" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
|
||||
<text x="0" y="155" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
|
||||
<text x="27" y="155" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
|
||||
<text x="72" y="155" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="172" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -2,13 +2,10 @@
|
||||
|
||||
exports[`AppHeader Icon Rendering > renders the default icon in standard terminals 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.0.0
|
||||
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -20,13 +17,10 @@ Tips for getting started:
|
||||
|
||||
exports[`AppHeader Icon Rendering > renders the symmetric icon in Apple Terminal 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▗▟▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.0.0
|
||||
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▗▟▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
|
||||
@@ -11,17 +11,6 @@ Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Choice question placeholder > uses default placeholder when not provided 2`] = `
|
||||
"Select your preferred language:
|
||||
|
||||
1. TypeScript
|
||||
2. JavaScript
|
||||
● 3. Enter a custom value
|
||||
|
||||
Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 1`] = `
|
||||
"Select your preferred language:
|
||||
|
||||
@@ -33,17 +22,6 @@ Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 2`] = `
|
||||
"Select your preferred language:
|
||||
|
||||
1. TypeScript
|
||||
2. JavaScript
|
||||
● 3. Type another language...
|
||||
|
||||
Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 1`] = `
|
||||
"Choose an option
|
||||
|
||||
@@ -58,20 +36,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 2`] = `
|
||||
"Choose an option
|
||||
|
||||
▲
|
||||
● 1. Option 1
|
||||
Description 1
|
||||
2. Option 2
|
||||
Description 2
|
||||
▼
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 1`] = `
|
||||
"Choose an option
|
||||
|
||||
@@ -111,45 +75,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 2`] = `
|
||||
"Choose an option
|
||||
|
||||
● 1. Option 1
|
||||
Description 1
|
||||
2. Option 2
|
||||
Description 2
|
||||
3. Option 3
|
||||
Description 3
|
||||
4. Option 4
|
||||
Description 4
|
||||
5. Option 5
|
||||
Description 5
|
||||
6. Option 6
|
||||
Description 6
|
||||
7. Option 7
|
||||
Description 7
|
||||
8. Option 8
|
||||
Description 8
|
||||
9. Option 9
|
||||
Description 9
|
||||
10. Option 10
|
||||
Description 10
|
||||
11. Option 11
|
||||
Description 11
|
||||
12. Option 12
|
||||
Description 12
|
||||
13. Option 13
|
||||
Description 13
|
||||
14. Option 14
|
||||
Description 14
|
||||
15. Option 15
|
||||
Description 15
|
||||
16. Enter a custom value
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
|
||||
"What should we name this component?
|
||||
|
||||
@@ -292,19 +217,3 @@ exports[`AskUserDialog > verifies "All of the above" visual state with snapshot
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > verifies "All of the above" visual state with snapshot 2`] = `
|
||||
"Which features?
|
||||
(Select all that apply)
|
||||
|
||||
1. [x] TypeScript
|
||||
2. [x] ESLint
|
||||
● 3. [x] All of the above
|
||||
Select all options
|
||||
4. [ ] Enter a custom value
|
||||
Done
|
||||
Finish selection
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -14,12 +14,24 @@ Spinner Initializing...
|
||||
|
||||
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 1`] = `
|
||||
"
|
||||
Spinner Initializing...
|
||||
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
|
||||
"
|
||||
Spinner Initializing...
|
||||
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -27,33 +27,6 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback...
|
||||
|
||||
Enter to submit · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 1`] = `
|
||||
"Overview
|
||||
|
||||
@@ -81,33 +54,6 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests
|
||||
|
||||
Enter to submit · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = `
|
||||
" Error reading plan: File not found
|
||||
"
|
||||
@@ -194,33 +140,6 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback...
|
||||
|
||||
Enter to submit · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = `
|
||||
"Overview
|
||||
|
||||
@@ -248,33 +167,6 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests
|
||||
|
||||
Enter to submit · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = `
|
||||
" Error reading plan: File not found
|
||||
"
|
||||
|
||||
@@ -6,8 +6,6 @@ exports[`HooksDialog > snapshots > renders empty hooks dialog 1`] = `
|
||||
│ │
|
||||
│ No hooks configured. │
|
||||
│ │
|
||||
│ (Press Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -33,8 +31,6 @@ exports[`HooksDialog > snapshots > renders hook using command as name when name
|
||||
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
|
||||
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
|
||||
│ │
|
||||
│ (Press Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -61,8 +57,6 @@ exports[`HooksDialog > snapshots > renders hook with all metadata (matcher, sequ
|
||||
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
|
||||
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
|
||||
│ │
|
||||
│ (Press Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -99,8 +93,6 @@ exports[`HooksDialog > snapshots > renders hooks grouped by event name with enab
|
||||
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
|
||||
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
|
||||
│ │
|
||||
│ (Press Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -127,8 +119,6 @@ exports[`HooksDialog > snapshots > renders single hook with security warning, so
|
||||
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
|
||||
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
|
||||
│ │
|
||||
│ (Press Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -78,27 +78,6 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> [Pasted Text: 10 lines]
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> [Pasted Text: 10 lines]
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> [Pasted Text: 10 lines]
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Type your message or @path/to/file
|
||||
|
||||
@@ -735,15 +735,6 @@ export const ToolConfirmationMessage: React.FC<
|
||||
paddingTop={0}
|
||||
paddingBottom={handlesOwnUI ? 0 : 1}
|
||||
>
|
||||
{/* System message from hook */}
|
||||
{confirmationDetails.systemMessage && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.status.warning}>
|
||||
{confirmationDetails.systemMessage}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{handlesOwnUI ? (
|
||||
bodyContent
|
||||
) : (
|
||||
|
||||
@@ -447,28 +447,6 @@ describe('BaseSelectionList', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should correctly calculate scroll offset during the initial render phase', async () => {
|
||||
// Verify that the component correctly calculates the scroll offset during the
|
||||
// initial render pass when starting with a high activeIndex.
|
||||
// List length 10, max items 3, activeIndex 9 (last item).
|
||||
const { unmount } = await renderScrollableList(9);
|
||||
|
||||
const renderedItemValues = mockRenderItem.mock.calls.map(
|
||||
(call) => call[0].value,
|
||||
);
|
||||
|
||||
// Item 1 (index 0) should not be rendered if the scroll offset is correctly
|
||||
// synchronized with the activeIndex from the start.
|
||||
expect(renderedItemValues).not.toContain('Item 1');
|
||||
|
||||
// The items at the end of the list should be rendered.
|
||||
expect(renderedItemValues).toContain('Item 8');
|
||||
expect(renderedItemValues).toContain('Item 9');
|
||||
expect(renderedItemValues).toContain('Item 10');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle maxItemsToShow larger than the list length', async () => {
|
||||
const { lastFrame, unmount } = await renderComponent(
|
||||
{ items: longList, maxItemsToShow: 15 },
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import {
|
||||
@@ -84,27 +84,20 @@ export function BaseSelectionList<
|
||||
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
|
||||
// Derive the effective scroll offset during render to avoid "no-selection" flicker.
|
||||
// This ensures that the visibleItems calculation uses an offset that includes activeIndex.
|
||||
let effectiveScrollOffset = scrollOffset;
|
||||
if (activeIndex < effectiveScrollOffset) {
|
||||
effectiveScrollOffset = activeIndex;
|
||||
} else if (activeIndex >= effectiveScrollOffset + maxItemsToShow) {
|
||||
effectiveScrollOffset = Math.max(
|
||||
// Handle scrolling for long lists
|
||||
useEffect(() => {
|
||||
const newScrollOffset = Math.max(
|
||||
0,
|
||||
Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow),
|
||||
);
|
||||
}
|
||||
if (activeIndex < scrollOffset) {
|
||||
setScrollOffset(activeIndex);
|
||||
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
|
||||
setScrollOffset(newScrollOffset);
|
||||
}
|
||||
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
|
||||
|
||||
// Synchronize state if it changed during derivation
|
||||
if (effectiveScrollOffset !== scrollOffset) {
|
||||
setScrollOffset(effectiveScrollOffset);
|
||||
}
|
||||
|
||||
const visibleItems = items.slice(
|
||||
effectiveScrollOffset,
|
||||
effectiveScrollOffset + maxItemsToShow,
|
||||
);
|
||||
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
|
||||
const numberColumnWidth = String(items.length).length;
|
||||
|
||||
return (
|
||||
@@ -112,18 +105,14 @@ export function BaseSelectionList<
|
||||
{/* Use conditional coloring instead of conditional rendering */}
|
||||
{showScrollArrows && items.length > maxItemsToShow && (
|
||||
<Text
|
||||
color={
|
||||
effectiveScrollOffset > 0
|
||||
? theme.text.primary
|
||||
: theme.text.secondary
|
||||
}
|
||||
color={scrollOffset > 0 ? theme.text.primary : theme.text.secondary}
|
||||
>
|
||||
▲
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{visibleItems.map((item, index) => {
|
||||
const itemIndex = effectiveScrollOffset + index;
|
||||
const itemIndex = scrollOffset + index;
|
||||
const isSelected = activeIndex === itemIndex;
|
||||
|
||||
// Determine colors based on selection and disabled state
|
||||
@@ -193,7 +182,7 @@ export function BaseSelectionList<
|
||||
{showScrollArrows && items.length > maxItemsToShow && (
|
||||
<Text
|
||||
color={
|
||||
effectiveScrollOffset + maxItemsToShow < items.length
|
||||
scrollOffset + maxItemsToShow < items.length
|
||||
? theme.text.primary
|
||||
: theme.text.secondary
|
||||
}
|
||||
|
||||
@@ -505,9 +505,7 @@ export const useSlashCommandProcessor = (
|
||||
const props = result.props as Record<string, unknown>;
|
||||
if (
|
||||
!props ||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof props['name'] !== 'string' ||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof props['displayName'] !== 'string' ||
|
||||
!props['definition']
|
||||
) {
|
||||
|
||||
@@ -674,7 +674,6 @@ describe('useAtCompletion', () => {
|
||||
multiDirTmpDirs.push(addedDir);
|
||||
|
||||
const multiDirConfig = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockConfig,
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
getDirectories: () => [cwdDir, addedDir],
|
||||
@@ -707,7 +706,6 @@ describe('useAtCompletion', () => {
|
||||
const directories = [cwdDir];
|
||||
|
||||
const dynamicConfig = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockConfig,
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
getDirectories: () => [...directories],
|
||||
@@ -752,7 +750,6 @@ describe('useAtCompletion', () => {
|
||||
multiDirTmpDirs.push(dir2);
|
||||
|
||||
const multiDirConfig = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockConfig,
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
getDirectories: () => [dir1, dir2],
|
||||
|
||||
@@ -32,10 +32,7 @@ import type {
|
||||
Config,
|
||||
EditorType,
|
||||
AnyToolInvocation,
|
||||
AnyDeclarativeTool,
|
||||
SpanMetadata,
|
||||
CompletedToolCall,
|
||||
ToolCallRequestInfo,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
@@ -55,11 +52,7 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part, PartListUnion } from '@google/genai';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import type {
|
||||
SlashCommandProcessorResult,
|
||||
HistoryItemWithoutId,
|
||||
HistoryItem,
|
||||
} from '../types.js';
|
||||
import type { SlashCommandProcessorResult } from '../types.js';
|
||||
import { MessageType, StreamingState } from '../types.js';
|
||||
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
@@ -250,10 +243,8 @@ describe('useGeminiStream', () => {
|
||||
let mockMarkToolsAsSubmitted: Mock;
|
||||
let handleAtCommandSpy: MockInstance;
|
||||
|
||||
const emptyHistory: HistoryItem[] = [];
|
||||
let capturedOnComplete:
|
||||
| ((tools: CompletedToolCall[]) => Promise<void>)
|
||||
| null = null;
|
||||
const emptyHistory: any[] = [];
|
||||
let capturedOnComplete: any = null;
|
||||
const mockGetPreferredEditor = vi.fn(() => 'vscode' as EditorType);
|
||||
const mockOnAuthError = vi.fn();
|
||||
const mockPerformMemoryRefresh = vi.fn(() => Promise.resolve());
|
||||
@@ -412,17 +403,13 @@ describe('useGeminiStream', () => {
|
||||
lastToolCalls,
|
||||
mockScheduleToolCalls,
|
||||
mockMarkToolsAsSubmitted,
|
||||
(
|
||||
updater:
|
||||
| TrackedToolCall[]
|
||||
| ((prev: TrackedToolCall[]) => TrackedToolCall[]),
|
||||
) => {
|
||||
(updater: any) => {
|
||||
lastToolCalls =
|
||||
typeof updater === 'function' ? updater(lastToolCalls) : updater;
|
||||
rerender({ ...initialProps, toolCalls: lastToolCalls });
|
||||
},
|
||||
(signal: AbortSignal) => {
|
||||
mockCancelAllToolCalls(signal);
|
||||
(...args: any[]) => {
|
||||
mockCancelAllToolCalls(...args);
|
||||
lastToolCalls = lastToolCalls.map((tc) => {
|
||||
if (
|
||||
tc.status === CoreToolCallStatus.AwaitingApproval ||
|
||||
@@ -983,7 +970,7 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
|
||||
it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => {
|
||||
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
|
||||
const stopExecutionToolCalls: TrackedToolCall[] = [
|
||||
{
|
||||
request: {
|
||||
callId: 'stop-call',
|
||||
@@ -1055,7 +1042,7 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
|
||||
it('should add a compact suppressed-error note before STOP_EXECUTION terminal info in low verbosity mode', async () => {
|
||||
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
|
||||
const stopExecutionToolCalls: TrackedToolCall[] = [
|
||||
{
|
||||
request: {
|
||||
callId: 'stop-call',
|
||||
@@ -1082,7 +1069,6 @@ describe('useGeminiStream', () => {
|
||||
} as unknown as TrackedCompletedToolCall,
|
||||
];
|
||||
const lowVerbositySettings = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockLoadedSettings,
|
||||
merged: {
|
||||
...mockLoadedSettings.merged,
|
||||
@@ -1936,120 +1922,6 @@ describe('useGeminiStream', () => {
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should record client-initiated tool calls in GeminiChat history', async () => {
|
||||
const { result, client: mockGeminiClient } = await renderTestHook();
|
||||
|
||||
mockHandleSlashCommand.mockResolvedValue({
|
||||
type: 'schedule_tool',
|
||||
toolName: 'activate_skill',
|
||||
toolArgs: { name: 'test-skill' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/test-skill');
|
||||
});
|
||||
|
||||
// Simulate tool completion
|
||||
const completedTool = {
|
||||
request: {
|
||||
callId: 'test-call-id',
|
||||
name: 'activate_skill',
|
||||
args: { name: 'test-skill' },
|
||||
isClientInitiated: true,
|
||||
},
|
||||
status: CoreToolCallStatus.Success,
|
||||
invocation: {
|
||||
getDescription: () => 'Activating skill test-skill',
|
||||
},
|
||||
tool: {
|
||||
isOutputMarkdown: true,
|
||||
},
|
||||
response: {
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'activate_skill',
|
||||
response: { content: 'skill instructions' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as TrackedCompletedToolCall;
|
||||
|
||||
await act(async () => {
|
||||
if (capturedOnComplete) {
|
||||
await capturedOnComplete([completedTool]);
|
||||
}
|
||||
});
|
||||
|
||||
// Verify that the tool call and response were added to GeminiChat history
|
||||
expect(mockGeminiClient.addHistory).toHaveBeenCalledWith({
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'activate_skill',
|
||||
args: { name: 'test-skill' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockGeminiClient.addHistory).toHaveBeenCalledWith({
|
||||
role: 'user',
|
||||
parts: completedTool.response.responseParts,
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT record other client-initiated tool calls (like save_memory) in history', async () => {
|
||||
const { result, client: mockGeminiClient } = await renderTestHook();
|
||||
|
||||
mockHandleSlashCommand.mockResolvedValue({
|
||||
type: 'schedule_tool',
|
||||
toolName: 'save_memory',
|
||||
toolArgs: { fact: 'test fact' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/memory add "test fact"');
|
||||
});
|
||||
|
||||
// Simulate tool completion
|
||||
const completedTool = {
|
||||
request: {
|
||||
callId: 'test-call-id',
|
||||
name: 'save_memory',
|
||||
args: { fact: 'test fact' },
|
||||
isClientInitiated: true,
|
||||
},
|
||||
status: CoreToolCallStatus.Success,
|
||||
invocation: {
|
||||
getDescription: () => 'Saving memory',
|
||||
},
|
||||
tool: {
|
||||
isOutputMarkdown: true,
|
||||
},
|
||||
response: {
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'save_memory',
|
||||
response: { success: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as TrackedCompletedToolCall;
|
||||
|
||||
await act(async () => {
|
||||
if (capturedOnComplete) {
|
||||
await capturedOnComplete([completedTool]);
|
||||
}
|
||||
});
|
||||
|
||||
// Verify that addHistory was NOT called
|
||||
expect(mockGeminiClient.addHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memory Refresh on save_memory', () => {
|
||||
@@ -2077,7 +1949,7 @@ describe('useGeminiStream', () => {
|
||||
displayName: 'save_memory',
|
||||
description: 'Saves memory',
|
||||
build: vi.fn(),
|
||||
} as unknown as AnyDeclarativeTool,
|
||||
} as any,
|
||||
invocation: {
|
||||
getDescription: () => `Mock description`,
|
||||
} as unknown as AnyToolInvocation,
|
||||
@@ -2151,7 +2023,6 @@ describe('useGeminiStream', () => {
|
||||
);
|
||||
|
||||
const testConfig = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockConfig,
|
||||
getContentGenerator: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(() => ({
|
||||
@@ -2317,7 +2188,7 @@ describe('useGeminiStream', () => {
|
||||
displayName: 'replace',
|
||||
description: 'Replace text',
|
||||
build: vi.fn(),
|
||||
} as unknown as AnyDeclarativeTool,
|
||||
} as any,
|
||||
invocation: {
|
||||
getDescription: () => 'Mock description',
|
||||
} as unknown as AnyToolInvocation,
|
||||
@@ -2358,7 +2229,7 @@ describe('useGeminiStream', () => {
|
||||
displayName: 'write_file',
|
||||
description: 'Write file',
|
||||
build: vi.fn(),
|
||||
} as unknown as AnyDeclarativeTool,
|
||||
} as any,
|
||||
invocation: {
|
||||
getDescription: () => 'Mock description',
|
||||
} as unknown as AnyToolInvocation,
|
||||
@@ -2703,14 +2574,14 @@ describe('useGeminiStream', () => {
|
||||
|
||||
it('should flush pending text rationale before scheduling tool calls to ensure correct history order', async () => {
|
||||
const addItemOrder: string[] = [];
|
||||
let capturedOnComplete: (tools: CompletedToolCall[]) => Promise<void>;
|
||||
let capturedOnComplete: any;
|
||||
|
||||
const mockScheduleToolCalls = vi.fn(async (requests) => {
|
||||
addItemOrder.push('scheduleToolCalls_START');
|
||||
// Simulate tools completing and triggering onComplete immediately.
|
||||
// This mimics the behavior that caused the regression where tool results
|
||||
// were added to history during the await scheduleToolCalls(...) block.
|
||||
const tools = requests.map((r: ToolCallRequestInfo) => ({
|
||||
const tools = requests.map((r: any) => ({
|
||||
request: r,
|
||||
status: CoreToolCallStatus.Success,
|
||||
tool: { displayName: r.name, name: r.name },
|
||||
@@ -2725,7 +2596,7 @@ describe('useGeminiStream', () => {
|
||||
addItemOrder.push('scheduleToolCalls_END');
|
||||
});
|
||||
|
||||
mockAddItem.mockImplementation((item: HistoryItemWithoutId) => {
|
||||
mockAddItem.mockImplementation((item: any) => {
|
||||
addItemOrder.push(`addItem:${item.type}`);
|
||||
});
|
||||
|
||||
@@ -2955,7 +2826,6 @@ describe('useGeminiStream', () => {
|
||||
describe('Thought Reset', () => {
|
||||
it('should keep full thinking entries in history when mode is full', async () => {
|
||||
const fullThinkingSettings: LoadedSettings = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockLoadedSettings,
|
||||
merged: {
|
||||
...mockLoadedSettings.merged,
|
||||
@@ -3379,9 +3249,8 @@ describe('useGeminiStream', () => {
|
||||
),
|
||||
);
|
||||
|
||||
// Reset fake timers to startTime because the asynchronous render lifecycle
|
||||
// (via waitUntilReady) advances the mock clock while waiting for initial
|
||||
// components to settle.
|
||||
// Reset start time after hook render, because renderHook (async)
|
||||
// advances fake timers by 50ms during its internal waitUntilReady() check.
|
||||
vi.setSystemTime(startTime);
|
||||
|
||||
// Submit query
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
getPlanModeExitMessage,
|
||||
isBackgroundExecutionData,
|
||||
Kind,
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
@@ -549,9 +548,11 @@ export const useGeminiStream = (
|
||||
if (tc.request.name === ASK_USER_TOOL_NAME && isInProgress) {
|
||||
return false;
|
||||
}
|
||||
// ToolGroupMessage now shows all non-canceled tools, so they are visible
|
||||
// in pending and we need to draw the closing border for them.
|
||||
return true;
|
||||
return (
|
||||
tc.status !== 'scheduled' &&
|
||||
tc.status !== 'validating' &&
|
||||
tc.status !== 'awaiting_approval'
|
||||
);
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -1657,7 +1658,7 @@ export const useGeminiStream = (
|
||||
) {
|
||||
let awaitingApprovalCalls = toolCalls.filter(
|
||||
(call): call is TrackedWaitingToolCall =>
|
||||
call.status === 'awaiting_approval' && !call.request.forcedAsk,
|
||||
call.status === 'awaiting_approval',
|
||||
);
|
||||
|
||||
// For AUTO_EDIT mode, only approve edit tools (replace, write_file)
|
||||
@@ -1721,36 +1722,6 @@ export const useGeminiStream = (
|
||||
);
|
||||
if (clientTools.length > 0) {
|
||||
markToolsAsSubmitted(clientTools.map((t) => t.request.callId));
|
||||
|
||||
if (geminiClient) {
|
||||
for (const tool of clientTools) {
|
||||
// Only manually record skill activations in the chat history.
|
||||
// Other client-initiated tools (like save_memory) update the system
|
||||
// prompt/context and don't strictly need to be in the history.
|
||||
if (tool.request.name !== ACTIVATE_SKILL_TOOL_NAME) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add both the call (model turn) and the result (user turn) to history.
|
||||
// Client-initiated calls are essentially "synthetic" turns that let
|
||||
// subsequent model calls understand what just happened in the UI.
|
||||
await geminiClient.addHistory({
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: tool.request.name,
|
||||
args: tool.request.args,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await geminiClient.addHistory({
|
||||
role: 'user',
|
||||
parts: tool.response.responseParts,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Identify new, successful save_memory calls that we haven't processed yet.
|
||||
|
||||
@@ -43,11 +43,10 @@ const CWD = '/test/project';
|
||||
const GIT_LOGS_HEAD_PATH = path.join(CWD, '.git', 'logs', 'HEAD');
|
||||
|
||||
describe('useGitBranchName', () => {
|
||||
let deferredSpawn: Array<{
|
||||
let deferredSpawn: {
|
||||
resolve: (val: { stdout: string; stderr: string }) => void;
|
||||
reject: (err: Error) => void;
|
||||
args: string[];
|
||||
}> = [];
|
||||
} | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vol.reset(); // Reset in-memory filesystem
|
||||
@@ -55,11 +54,11 @@ describe('useGitBranchName', () => {
|
||||
[GIT_LOGS_HEAD_PATH]: 'ref: refs/heads/main',
|
||||
});
|
||||
|
||||
deferredSpawn = [];
|
||||
deferredSpawn = null;
|
||||
vi.mocked(mockSpawnAsync).mockImplementation(
|
||||
(_command: string, args: string[]) =>
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
deferredSpawn.push({ resolve, reject, args });
|
||||
deferredSpawn = { resolve, reject };
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -92,9 +91,7 @@ describe('useGitBranchName', () => {
|
||||
expect(result.current).toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--abbrev-ref');
|
||||
spawn.resolve({ stdout: 'main\n', stderr: '' });
|
||||
deferredSpawn?.resolve({ stdout: 'main\n', stderr: '' });
|
||||
});
|
||||
|
||||
expect(result.current).toBe('main');
|
||||
@@ -104,9 +101,7 @@ describe('useGitBranchName', () => {
|
||||
const { result } = await renderGitBranchNameHook(CWD);
|
||||
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--abbrev-ref');
|
||||
spawn.reject(new Error('Git error'));
|
||||
deferredSpawn?.reject(new Error('Git error'));
|
||||
});
|
||||
|
||||
expect(result.current).toBeUndefined();
|
||||
@@ -116,16 +111,12 @@ describe('useGitBranchName', () => {
|
||||
const { result } = await renderGitBranchNameHook(CWD);
|
||||
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--abbrev-ref');
|
||||
spawn.resolve({ stdout: 'HEAD\n', stderr: '' });
|
||||
deferredSpawn?.resolve({ stdout: 'HEAD\n', stderr: '' });
|
||||
});
|
||||
|
||||
// It should now call spawnAsync again for the short hash
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--short');
|
||||
spawn.resolve({ stdout: 'a1b2c3d\n', stderr: '' });
|
||||
deferredSpawn?.resolve({ stdout: 'a1b2c3d\n', stderr: '' });
|
||||
});
|
||||
|
||||
expect(result.current).toBe('a1b2c3d');
|
||||
@@ -135,15 +126,11 @@ describe('useGitBranchName', () => {
|
||||
const { result } = await renderGitBranchNameHook(CWD);
|
||||
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--abbrev-ref');
|
||||
spawn.resolve({ stdout: 'HEAD\n', stderr: '' });
|
||||
deferredSpawn?.resolve({ stdout: 'HEAD\n', stderr: '' });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--short');
|
||||
spawn.reject(new Error('Git error'));
|
||||
deferredSpawn?.reject(new Error('Git error'));
|
||||
});
|
||||
|
||||
expect(result.current).toBeUndefined();
|
||||
@@ -156,9 +143,7 @@ describe('useGitBranchName', () => {
|
||||
const { result } = await renderGitBranchNameHook(CWD);
|
||||
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--abbrev-ref');
|
||||
spawn.resolve({ stdout: 'main\n', stderr: '' });
|
||||
deferredSpawn?.resolve({ stdout: 'main\n', stderr: '' });
|
||||
});
|
||||
|
||||
expect(result.current).toBe('main');
|
||||
@@ -175,9 +160,7 @@ describe('useGitBranchName', () => {
|
||||
|
||||
// Resolving the new branch name fetch
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--abbrev-ref');
|
||||
spawn.resolve({ stdout: 'develop\n', stderr: '' });
|
||||
deferredSpawn?.resolve({ stdout: 'develop\n', stderr: '' });
|
||||
});
|
||||
|
||||
expect(result.current).toBe('develop');
|
||||
@@ -190,9 +173,7 @@ describe('useGitBranchName', () => {
|
||||
const { result } = await renderGitBranchNameHook(CWD);
|
||||
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--abbrev-ref');
|
||||
spawn.resolve({ stdout: 'main\n', stderr: '' });
|
||||
deferredSpawn?.resolve({ stdout: 'main\n', stderr: '' });
|
||||
});
|
||||
|
||||
expect(result.current).toBe('main');
|
||||
@@ -207,8 +188,8 @@ describe('useGitBranchName', () => {
|
||||
fs.writeFileSync(GIT_LOGS_HEAD_PATH, 'ref: refs/heads/develop');
|
||||
});
|
||||
|
||||
// spawnAsync should NOT have been called again for updating
|
||||
expect(deferredSpawn.length).toBe(0);
|
||||
// spawnAsync should NOT have been called again
|
||||
expect(vi.mocked(mockSpawnAsync)).toHaveBeenCalledTimes(1);
|
||||
expect(result.current).toBe('main');
|
||||
});
|
||||
|
||||
@@ -222,9 +203,7 @@ describe('useGitBranchName', () => {
|
||||
const { unmount } = await renderGitBranchNameHook(CWD);
|
||||
|
||||
await act(async () => {
|
||||
const spawn = deferredSpawn.shift()!;
|
||||
expect(spawn.args).toContain('--abbrev-ref');
|
||||
spawn.resolve({ stdout: 'main\n', stderr: '' });
|
||||
deferredSpawn?.resolve({ stdout: 'main\n', stderr: '' });
|
||||
});
|
||||
|
||||
// Wait for watcher to be set up BEFORE unmounting
|
||||
|
||||
@@ -194,7 +194,6 @@ export class KeyBinding {
|
||||
|
||||
const key = remains;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
const isSingleChar = [...key].length === 1;
|
||||
|
||||
if (!isSingleChar && !KeyBinding.VALID_LONG_KEYS.has(key.toLowerCase())) {
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { ConsolePatcher } from './ConsolePatcher.js';
|
||||
|
||||
describe('ConsolePatcher', () => {
|
||||
let patcher: ConsolePatcher;
|
||||
const onNewMessage = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
if (patcher) {
|
||||
patcher.cleanup();
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should patch and restore console methods', () => {
|
||||
const beforeLog = console.log;
|
||||
const beforeWarn = console.warn;
|
||||
const beforeError = console.error;
|
||||
const beforeDebug = console.debug;
|
||||
const beforeInfo = console.info;
|
||||
|
||||
patcher = new ConsolePatcher({ onNewMessage, debugMode: false });
|
||||
patcher.patch();
|
||||
|
||||
expect(console.log).not.toBe(beforeLog);
|
||||
expect(console.warn).not.toBe(beforeWarn);
|
||||
expect(console.error).not.toBe(beforeError);
|
||||
expect(console.debug).not.toBe(beforeDebug);
|
||||
expect(console.info).not.toBe(beforeInfo);
|
||||
|
||||
patcher.cleanup();
|
||||
|
||||
expect(console.log).toBe(beforeLog);
|
||||
expect(console.warn).toBe(beforeWarn);
|
||||
expect(console.error).toBe(beforeError);
|
||||
expect(console.debug).toBe(beforeDebug);
|
||||
expect(console.info).toBe(beforeInfo);
|
||||
});
|
||||
|
||||
describe('Interactive mode', () => {
|
||||
it('should ignore log and info when it is not interactive and debugMode is false', () => {
|
||||
patcher = new ConsolePatcher({
|
||||
onNewMessage,
|
||||
debugMode: false,
|
||||
interactive: false,
|
||||
});
|
||||
patcher.patch();
|
||||
|
||||
console.log('test log');
|
||||
console.info('test info');
|
||||
expect(onNewMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not ignore log and info when it is not interactive and debugMode is true', () => {
|
||||
patcher = new ConsolePatcher({
|
||||
onNewMessage,
|
||||
debugMode: true,
|
||||
interactive: false,
|
||||
});
|
||||
patcher.patch();
|
||||
|
||||
console.log('test log');
|
||||
expect(onNewMessage).toHaveBeenCalledWith({
|
||||
type: 'log',
|
||||
content: 'test log',
|
||||
count: 1,
|
||||
});
|
||||
|
||||
console.info('test info');
|
||||
expect(onNewMessage).toHaveBeenCalledWith({
|
||||
type: 'info',
|
||||
content: 'test info',
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not ignore log and info when it is interactive', () => {
|
||||
patcher = new ConsolePatcher({
|
||||
onNewMessage,
|
||||
debugMode: false,
|
||||
interactive: true,
|
||||
});
|
||||
patcher.patch();
|
||||
|
||||
console.log('test log');
|
||||
expect(onNewMessage).toHaveBeenCalledWith({
|
||||
type: 'log',
|
||||
content: 'test log',
|
||||
count: 1,
|
||||
});
|
||||
|
||||
console.info('test info');
|
||||
expect(onNewMessage).toHaveBeenCalledWith({
|
||||
type: 'info',
|
||||
content: 'test info',
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when stderr is false', () => {
|
||||
it('should call onNewMessage for log, warn, error, and info', () => {
|
||||
patcher = new ConsolePatcher({
|
||||
onNewMessage,
|
||||
debugMode: false,
|
||||
stderr: false,
|
||||
});
|
||||
patcher.patch();
|
||||
|
||||
console.log('test log');
|
||||
expect(onNewMessage).toHaveBeenCalledWith({
|
||||
type: 'log',
|
||||
content: 'test log',
|
||||
count: 1,
|
||||
});
|
||||
|
||||
console.warn('test warn');
|
||||
expect(onNewMessage).toHaveBeenCalledWith({
|
||||
type: 'warn',
|
||||
content: 'test warn',
|
||||
count: 1,
|
||||
});
|
||||
|
||||
console.error('test error');
|
||||
expect(onNewMessage).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
content: 'test error',
|
||||
count: 1,
|
||||
});
|
||||
|
||||
console.info('test info');
|
||||
expect(onNewMessage).toHaveBeenCalledWith({
|
||||
type: 'info',
|
||||
content: 'test info',
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call onNewMessage for debug when debugMode is false', () => {
|
||||
patcher = new ConsolePatcher({
|
||||
onNewMessage,
|
||||
debugMode: false,
|
||||
stderr: false,
|
||||
});
|
||||
patcher.patch();
|
||||
|
||||
console.debug('test debug');
|
||||
expect(onNewMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onNewMessage for debug when debugMode is true', () => {
|
||||
patcher = new ConsolePatcher({
|
||||
onNewMessage,
|
||||
debugMode: true,
|
||||
stderr: false,
|
||||
});
|
||||
patcher.patch();
|
||||
|
||||
console.debug('test debug');
|
||||
expect(onNewMessage).toHaveBeenCalledWith({
|
||||
type: 'debug',
|
||||
content: 'test debug',
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should format multiple arguments using util.format', () => {
|
||||
patcher = new ConsolePatcher({
|
||||
onNewMessage,
|
||||
debugMode: false,
|
||||
stderr: false,
|
||||
});
|
||||
patcher.patch();
|
||||
|
||||
console.log('test %s %d', 'string', 123);
|
||||
expect(onNewMessage).toHaveBeenCalledWith({
|
||||
type: 'log',
|
||||
content: 'test string 123',
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when stderr is true', () => {
|
||||
it('should redirect warn and error to originalConsoleError', () => {
|
||||
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
patcher = new ConsolePatcher({ debugMode: false, stderr: true });
|
||||
patcher.patch();
|
||||
|
||||
console.warn('test warn');
|
||||
expect(spyError).toHaveBeenCalledWith('test warn');
|
||||
|
||||
console.error('test error');
|
||||
expect(spyError).toHaveBeenCalledWith('test error');
|
||||
});
|
||||
|
||||
it('should redirect log and info to originalConsoleError when debugMode is true', () => {
|
||||
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
patcher = new ConsolePatcher({ debugMode: true, stderr: true });
|
||||
patcher.patch();
|
||||
|
||||
console.log('test log');
|
||||
expect(spyError).toHaveBeenCalledWith('test log');
|
||||
|
||||
console.info('test info');
|
||||
expect(spyError).toHaveBeenCalledWith('test info');
|
||||
});
|
||||
|
||||
it('should ignore debug when debugMode is false', () => {
|
||||
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
patcher = new ConsolePatcher({ debugMode: false, stderr: true });
|
||||
patcher.patch();
|
||||
|
||||
console.debug('test debug');
|
||||
expect(spyError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should redirect debug to originalConsoleError when debugMode is true', () => {
|
||||
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
patcher = new ConsolePatcher({ debugMode: true, stderr: true });
|
||||
patcher.patch();
|
||||
|
||||
console.debug('test debug');
|
||||
expect(spyError).toHaveBeenCalledWith('test debug');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,6 @@ interface ConsolePatcherParams {
|
||||
onNewMessage?: (message: Omit<ConsoleMessageItem, 'id'>) => void;
|
||||
debugMode: boolean;
|
||||
stderr?: boolean;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export class ConsolePatcher {
|
||||
@@ -50,19 +49,12 @@ export class ConsolePatcher {
|
||||
private patchConsoleMethod =
|
||||
(type: 'log' | 'warn' | 'error' | 'debug' | 'info') =>
|
||||
(...args: unknown[]) => {
|
||||
// When it is non interactive mode, do not show info logging unless
|
||||
// it is debug mode. default to true if it is undefined.
|
||||
if (this.params.interactive === false) {
|
||||
if ((type === 'info' || type === 'log') && !this.params.debugMode) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// When it is in the debug mode, redirect console output to stderr
|
||||
// depending on if it is stderr only mode.
|
||||
if (type !== 'debug' || this.params.debugMode) {
|
||||
if (this.params.stderr) {
|
||||
if (this.params.stderr) {
|
||||
if (type !== 'debug' || this.params.debugMode) {
|
||||
this.originalConsoleError(this.formatArgs(args));
|
||||
} else {
|
||||
}
|
||||
} else {
|
||||
if (type !== 'debug' || this.params.debugMode) {
|
||||
this.params.onNewMessage?.({
|
||||
type,
|
||||
content: this.formatArgs(args),
|
||||
|
||||
@@ -1,45 +1,32 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="343" viewBox="0 0 920 343">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="343" fill="#000000" />
|
||||
<rect width="920" height="207" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="19" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛</text>
|
||||
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="36" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌</text>
|
||||
<text x="18" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="90" y="53" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌</text>
|
||||
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="90" y="70" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs"> ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀</text>
|
||||
<text x="9" y="104" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="99" y="104" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
|
||||
<text x="0" y="155" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
|
||||
<text x="0" y="172" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
|
||||
<text x="90" y="172" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
|
||||
<text x="171" y="172" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
|
||||
<text x="27" y="189" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
|
||||
<text x="72" y="189" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="206" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="223" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
<text x="0" y="240" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="257" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
|
||||
<text x="855" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="291" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
|
||||
<text x="855" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="180" y="19" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
|
||||
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="0" y="104" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="121" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
|
||||
<text x="855" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="155" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
|
||||
<text x="855" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="172" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 3.3 KiB |
@@ -1,45 +1,32 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="343" viewBox="0 0 920 343">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="343" fill="#000000" />
|
||||
<rect width="920" height="207" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="19" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛</text>
|
||||
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="36" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌</text>
|
||||
<text x="18" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="90" y="53" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌</text>
|
||||
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="90" y="70" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs"> ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀</text>
|
||||
<text x="9" y="104" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="99" y="104" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
|
||||
<text x="0" y="155" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
|
||||
<text x="0" y="172" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
|
||||
<text x="90" y="172" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
|
||||
<text x="171" y="172" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
|
||||
<text x="27" y="189" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
|
||||
<text x="72" y="189" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="206" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="223" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
<text x="0" y="240" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="257" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="257" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="257" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">run_shell_command</text>
|
||||
<text x="855" y="257" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="274" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="274" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="291" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="291" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Running command...</text>
|
||||
<text x="855" y="291" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="180" y="19" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
|
||||
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="0" y="104" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="121" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="121" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="121" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">run_shell_command</text>
|
||||
<text x="855" y="121" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="138" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="138" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="155" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="155" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Running command...</text>
|
||||
<text x="855" y="155" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="172" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 3.3 KiB |
@@ -1,45 +1,32 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="343" viewBox="0 0 920 343">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="343" fill="#000000" />
|
||||
<rect width="920" height="207" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="19" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛</text>
|
||||
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="36" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌</text>
|
||||
<text x="18" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="90" y="53" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌</text>
|
||||
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="90" y="70" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs"> ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀</text>
|
||||
<text x="9" y="104" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="99" y="104" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
|
||||
<text x="0" y="155" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
|
||||
<text x="0" y="172" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
|
||||
<text x="90" y="172" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
|
||||
<text x="171" y="172" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
|
||||
<text x="27" y="189" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
|
||||
<text x="72" y="189" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="206" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="223" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
<text x="0" y="240" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="257" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
|
||||
<text x="855" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="291" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
|
||||
<text x="855" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="180" y="19" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
|
||||
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="0" y="104" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="121" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
|
||||
<text x="855" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="155" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
|
||||
<text x="855" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="172" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 3.3 KiB |
@@ -2,19 +2,11 @@
|
||||
|
||||
exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for a pending search dialog (google_web_search) 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
Gemini CLI v1.2.3
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ google_web_search │
|
||||
│ │
|
||||
@@ -24,19 +16,11 @@ Tips for getting started:
|
||||
|
||||
exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for a shell tool 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
Gemini CLI v1.2.3
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ run_shell_command │
|
||||
│ │
|
||||
@@ -46,19 +30,11 @@ Tips for getting started:
|
||||
|
||||
exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for an empty slice following a search tool 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
Gemini CLI v1.2.3
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ google_web_search │
|
||||
│ │
|
||||
|
||||
@@ -13,14 +13,12 @@ import {
|
||||
disableModifyOtherKeys,
|
||||
enableBracketedPasteMode,
|
||||
disableBracketedPasteMode,
|
||||
disableMouseEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { parseColor } from '../themes/color-utils.js';
|
||||
|
||||
export type TerminalBackgroundColor = string | undefined;
|
||||
|
||||
const TERMINAL_CLEANUP_SEQUENCE =
|
||||
'\x1b[<u\x1b[>4;0m\x1b[?2004l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l';
|
||||
const TERMINAL_CLEANUP_SEQUENCE = '\x1b[<u\x1b[>4;0m\x1b[?2004l';
|
||||
|
||||
export function cleanupTerminalOnExit() {
|
||||
try {
|
||||
@@ -35,7 +33,6 @@ export function cleanupTerminalOnExit() {
|
||||
disableKittyKeyboardProtocol();
|
||||
disableModifyOtherKeys();
|
||||
disableBracketedPasteMode();
|
||||
disableMouseEvents();
|
||||
}
|
||||
|
||||
export class TerminalCapabilityManager {
|
||||
|
||||
@@ -502,6 +502,7 @@ export function useTerminalSetupPrompt({
|
||||
if (hasBeenPrompted) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
|
||||
@@ -514,7 +514,6 @@ describe('textUtils', () => {
|
||||
const b = sanitized.b as { c: string; d: Array<string | object> };
|
||||
expect(b.c).toBe('\\u001b[32mgreen\\u001b[0m');
|
||||
expect(b.d[0]).toBe('\\u001b[33myellow\\u001b[0m');
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
if (typeof b.d[1] === 'object' && b.d[1] !== null) {
|
||||
const e = b.d[1] as { e: string };
|
||||
expect(e.e).toBe('\\u001b[34mblue\\u001b[0m');
|
||||
|
||||
@@ -40,8 +40,8 @@ const agentStrategy: FeatureToggleStrategy = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Enables an agent by setting `agents.overrides.<agentName>.enabled` to `true`
|
||||
* in available writable scopes (User and Workspace).
|
||||
* Enables an agent by ensuring it is enabled in any writable scope (User and Workspace).
|
||||
* It sets `agents.overrides.<agentName>.enabled` to `true`.
|
||||
*/
|
||||
export function enableAgent(
|
||||
settings: LoadedSettings,
|
||||
@@ -59,8 +59,7 @@ export function enableAgent(
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables an agent by setting `agents.overrides.<agentName>.enabled` to `false`
|
||||
* in the specified scope.
|
||||
* Disables an agent by setting `agents.overrides.<agentName>.enabled` to `false` in the specified scope.
|
||||
*/
|
||||
export function disableAgent(
|
||||
settings: LoadedSettings,
|
||||
|
||||
@@ -72,46 +72,6 @@ describe('cleanup', () => {
|
||||
expect(asyncFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should run cleanupFunctions BEFORE draining stdin and BEFORE runSyncCleanup', async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
// Cleanup function
|
||||
registerCleanup(() => {
|
||||
callOrder.push('cleanup');
|
||||
});
|
||||
|
||||
// Sync cleanup function (e.g. setRawMode(false))
|
||||
registerSyncCleanup(() => {
|
||||
callOrder.push('sync');
|
||||
});
|
||||
|
||||
// Mock stdin.resume to track drainStdin
|
||||
const originalResume = process.stdin.resume;
|
||||
process.stdin.resume = vi.fn().mockImplementation(() => {
|
||||
callOrder.push('drain');
|
||||
return process.stdin;
|
||||
});
|
||||
|
||||
// Mock stdin properties for drainStdin
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await runExitCleanup();
|
||||
} finally {
|
||||
process.stdin.resume = originalResume;
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: originalIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
expect(callOrder).toEqual(['drain', 'drain', 'sync', 'cleanup']);
|
||||
});
|
||||
|
||||
it('should continue running cleanup functions even if one throws an error', async () => {
|
||||
const errorFn = vi.fn().mockImplementation(() => {
|
||||
throw new Error('test error');
|
||||
@@ -223,7 +183,6 @@ describe('signal and TTY handling', () => {
|
||||
|
||||
const sigtermHandlers = processOnHandlers.get('SIGTERM') || [];
|
||||
expect(sigtermHandlers.length).toBeGreaterThan(0);
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
expect(typeof sigtermHandlers[0]).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@ export function registerTelemetryConfig(config: Config) {
|
||||
|
||||
export async function runExitCleanup() {
|
||||
// drain stdin to prevent printing garbage on exit
|
||||
// https://github.com/google-gemini/gemini-cli/issues/16801
|
||||
// https://github.com/google-gemini/gemini-cli/issues/1680
|
||||
await drainStdin();
|
||||
|
||||
runSyncCleanup();
|
||||
|
||||
@@ -214,7 +214,6 @@ describe('listSessions', () => {
|
||||
// Get all the session log calls (skip the header)
|
||||
const sessionCalls = mocks.writeToStdout.mock.calls.filter(
|
||||
(call): call is [string] =>
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof call[0] === 'string' &&
|
||||
call[0].includes('[session-') &&
|
||||
!call[0].includes('Available sessions'),
|
||||
|
||||
@@ -30,9 +30,6 @@ process.env.FORCE_COLOR = '3';
|
||||
// Force generic keybinding hints to ensure stable snapshots across different operating systems.
|
||||
process.env.FORCE_GENERIC_KEYBINDING_HINTS = 'true';
|
||||
|
||||
// Force generic terminal declaration to ensure stable snapshots across different host environments.
|
||||
process.env.TERM_PROGRAM = 'generic';
|
||||
|
||||
import './src/test-utils/customMatchers.js';
|
||||
|
||||
let consoleErrorSpy: vi.SpyInstance;
|
||||
|
||||
@@ -83,55 +83,18 @@ async function bundle() {
|
||||
}
|
||||
fs.cpSync(srcThirdParty, destThirdParty, {
|
||||
recursive: true,
|
||||
filter: (src) => {
|
||||
// Skip large/unnecessary bundles that are either explicitly excluded
|
||||
// or not required for the browser agent functionality.
|
||||
return (
|
||||
!src.includes('lighthouse-devtools-mcp-bundle.js') &&
|
||||
!src.includes('devtools-formatter-worker.js')
|
||||
);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.warn(`Warning: third_party assets not found at ${srcThirdParty}`);
|
||||
}
|
||||
|
||||
// Copy watchdog scripts and dependencies
|
||||
const srcTelemetry = path.resolve(
|
||||
__dirname,
|
||||
'../../../node_modules/chrome-devtools-mcp/build/src/telemetry',
|
||||
);
|
||||
const destWatchdog = path.resolve(
|
||||
__dirname,
|
||||
'../dist/bundled/watchdog',
|
||||
);
|
||||
if (fs.existsSync(srcTelemetry)) {
|
||||
fs.mkdirSync(destWatchdog, { recursive: true });
|
||||
// Copy main watchdog directory
|
||||
fs.cpSync(path.join(srcTelemetry, 'watchdog'), destWatchdog, { recursive: true });
|
||||
// Copy shared types needed by watchdog
|
||||
fs.copyFileSync(
|
||||
path.join(srcTelemetry, 'types.js'),
|
||||
path.resolve(__dirname, '../dist/bundled/types.js')
|
||||
);
|
||||
// Copy logger needed by watchdog
|
||||
fs.copyFileSync(
|
||||
path.join(srcTelemetry, '../logger.js'),
|
||||
path.resolve(__dirname, '../dist/bundled/logger.js')
|
||||
);
|
||||
|
||||
// Patch imports in watchdog files to reflect the flattened structure in dist/bundled/
|
||||
const watchdogFiles = fs.readdirSync(destWatchdog);
|
||||
for (const file of watchdogFiles) {
|
||||
if (file.endsWith('.js')) {
|
||||
const filePath = path.join(destWatchdog, file);
|
||||
let content = fs.readFileSync(filePath, 'utf-8');
|
||||
content = content.replace(/\.\.\/\.\.\/logger\.js/g, '../logger.js');
|
||||
content = content.replace(/\.\.\/types\.js/g, '../types.js');
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(`Warning: telemetry directory not found at ${srcTelemetry}`);
|
||||
}
|
||||
|
||||
// Patch the bundled file to point to the correct watchdog path
|
||||
// The original code uses new URL("./watchdog/main.js", import.meta.url)
|
||||
// which resolves relative to the bundled file.
|
||||
// Our bundling script copies the watchdog to ./watchdog/main.js relative to the bundle.
|
||||
// So the original code should work IF esbuild doesn't mangle import.meta.url.
|
||||
} catch (error) {
|
||||
console.error('Error bundling chrome-devtools-mcp:', error);
|
||||
process.exit(1);
|
||||
|
||||
@@ -32,7 +32,9 @@ describe('AgentSession', () => {
|
||||
await session.abort();
|
||||
expect(
|
||||
session.events.some(
|
||||
(e) => e.type === 'agent_end' && e.reason === 'aborted',
|
||||
(e) =>
|
||||
e.type === 'agent_end' &&
|
||||
(e as AgentEvent<'agent_end'>).reason === 'aborted',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,733 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { FinishReason } from '@google/genai';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
import {
|
||||
translateEvent,
|
||||
createTranslationState,
|
||||
mapFinishReason,
|
||||
mapHttpToGrpcStatus,
|
||||
mapError,
|
||||
mapUsage,
|
||||
type TranslationState,
|
||||
} from './event-translator.js';
|
||||
import { GeminiEventType } from '../core/turn.js';
|
||||
import type { ServerGeminiStreamEvent } from '../core/turn.js';
|
||||
import type { AgentEvent } from './types.js';
|
||||
|
||||
describe('createTranslationState', () => {
|
||||
it('creates state with default streamId', () => {
|
||||
const state = createTranslationState();
|
||||
expect(state.streamId).toBeDefined();
|
||||
expect(state.streamStartEmitted).toBe(false);
|
||||
expect(state.model).toBeUndefined();
|
||||
expect(state.eventCounter).toBe(0);
|
||||
expect(state.pendingToolNames.size).toBe(0);
|
||||
});
|
||||
|
||||
it('creates state with custom streamId', () => {
|
||||
const state = createTranslationState('custom-stream');
|
||||
expect(state.streamId).toBe('custom-stream');
|
||||
});
|
||||
});
|
||||
|
||||
describe('translateEvent', () => {
|
||||
let state: TranslationState;
|
||||
|
||||
beforeEach(() => {
|
||||
state = createTranslationState('test-stream');
|
||||
});
|
||||
|
||||
describe('Content events', () => {
|
||||
it('emits agent_start + message for first content event', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hello world',
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]?.type).toBe('agent_start');
|
||||
expect(result[1]?.type).toBe('message');
|
||||
const msg = result[1] as AgentEvent<'message'>;
|
||||
expect(msg.role).toBe('agent');
|
||||
expect(msg.content).toEqual([{ type: 'text', text: 'Hello world' }]);
|
||||
});
|
||||
|
||||
it('skips agent_start for subsequent content events', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Content,
|
||||
value: 'more text',
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.type).toBe('message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Thought events', () => {
|
||||
it('emits thought content with metadata', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Thought,
|
||||
value: { subject: 'Planning', description: 'I am thinking...' },
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const msg = result[0] as AgentEvent<'message'>;
|
||||
expect(msg.content).toEqual([
|
||||
{ type: 'thought', thought: 'I am thinking...' },
|
||||
]);
|
||||
expect(msg._meta?.['subject']).toBe('Planning');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolCallRequest events', () => {
|
||||
it('emits tool_request and tracks pending tool name', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'call-1',
|
||||
name: 'read_file',
|
||||
args: { path: '/tmp/test' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'p1',
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const req = result[0] as AgentEvent<'tool_request'>;
|
||||
expect(req.requestId).toBe('call-1');
|
||||
expect(req.name).toBe('read_file');
|
||||
expect(req.args).toEqual({ path: '/tmp/test' });
|
||||
expect(state.pendingToolNames.get('call-1')).toBe('read_file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolCallResponse events', () => {
|
||||
it('emits tool_response with content from responseParts', () => {
|
||||
state.streamStartEmitted = true;
|
||||
state.pendingToolNames.set('call-1', 'read_file');
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallResponse,
|
||||
value: {
|
||||
callId: 'call-1',
|
||||
responseParts: [{ text: 'file contents' }],
|
||||
resultDisplay: undefined,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const resp = result[0] as AgentEvent<'tool_response'>;
|
||||
expect(resp.requestId).toBe('call-1');
|
||||
expect(resp.name).toBe('read_file');
|
||||
expect(resp.content).toEqual([{ type: 'text', text: 'file contents' }]);
|
||||
expect(resp.isError).toBe(false);
|
||||
expect(state.pendingToolNames.has('call-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('uses error.message for content when tool errored', () => {
|
||||
state.streamStartEmitted = true;
|
||||
state.pendingToolNames.set('call-2', 'write_file');
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallResponse,
|
||||
value: {
|
||||
callId: 'call-2',
|
||||
responseParts: [{ text: 'stale parts' }],
|
||||
resultDisplay: 'Permission denied',
|
||||
error: new Error('Permission denied to write'),
|
||||
errorType: ToolErrorType.PERMISSION_DENIED,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const resp = result[0] as AgentEvent<'tool_response'>;
|
||||
expect(resp.isError).toBe(true);
|
||||
// Should use error.message, not responseParts
|
||||
expect(resp.content).toEqual([
|
||||
{ type: 'text', text: 'Permission denied to write' },
|
||||
]);
|
||||
expect(resp.displayContent).toEqual([
|
||||
{ type: 'text', text: 'Permission denied' },
|
||||
]);
|
||||
expect(resp.data).toEqual({ errorType: 'permission_denied' });
|
||||
});
|
||||
|
||||
it('uses "unknown" name for untracked tool calls', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallResponse,
|
||||
value: {
|
||||
callId: 'untracked',
|
||||
responseParts: [{ text: 'data' }],
|
||||
resultDisplay: undefined,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
const resp = result[0] as AgentEvent<'tool_response'>;
|
||||
expect(resp.name).toBe('unknown');
|
||||
});
|
||||
|
||||
it('stringifies object resultDisplay correctly', () => {
|
||||
state.streamStartEmitted = true;
|
||||
state.pendingToolNames.set('call-3', 'diff_tool');
|
||||
const objectDisplay = {
|
||||
fileDiff: '@@ -1 +1 @@\n-a\n+b',
|
||||
fileName: 'test.txt',
|
||||
filePath: '/tmp/test.txt',
|
||||
originalContent: 'a',
|
||||
newContent: 'b',
|
||||
};
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallResponse,
|
||||
value: {
|
||||
callId: 'call-3',
|
||||
responseParts: [{ text: 'diff result' }],
|
||||
resultDisplay: objectDisplay,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
const resp = result[0] as AgentEvent<'tool_response'>;
|
||||
expect(resp.displayContent).toEqual([
|
||||
{ type: 'text', text: JSON.stringify(objectDisplay) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes through string resultDisplay as-is', () => {
|
||||
state.streamStartEmitted = true;
|
||||
state.pendingToolNames.set('call-4', 'shell');
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallResponse,
|
||||
value: {
|
||||
callId: 'call-4',
|
||||
responseParts: [{ text: 'output' }],
|
||||
resultDisplay: 'Command output text',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
const resp = result[0] as AgentEvent<'tool_response'>;
|
||||
expect(resp.displayContent).toEqual([
|
||||
{ type: 'text', text: 'Command output text' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves outputFile and contentLength in data', () => {
|
||||
state.streamStartEmitted = true;
|
||||
state.pendingToolNames.set('call-5', 'write_file');
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallResponse,
|
||||
value: {
|
||||
callId: 'call-5',
|
||||
responseParts: [{ text: 'written' }],
|
||||
resultDisplay: undefined,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
outputFile: '/tmp/out.txt',
|
||||
contentLength: 42,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
const resp = result[0] as AgentEvent<'tool_response'>;
|
||||
expect(resp.data?.['outputFile']).toBe('/tmp/out.txt');
|
||||
expect(resp.data?.['contentLength']).toBe(42);
|
||||
});
|
||||
|
||||
it('handles multi-part responses (text + inlineData)', () => {
|
||||
state.streamStartEmitted = true;
|
||||
state.pendingToolNames.set('call-6', 'screenshot');
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallResponse,
|
||||
value: {
|
||||
callId: 'call-6',
|
||||
responseParts: [
|
||||
{ text: 'Here is the screenshot' },
|
||||
{ inlineData: { data: 'base64img', mimeType: 'image/png' } },
|
||||
],
|
||||
resultDisplay: undefined,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
const resp = result[0] as AgentEvent<'tool_response'>;
|
||||
expect(resp.content).toEqual([
|
||||
{ type: 'text', text: 'Here is the screenshot' },
|
||||
{ type: 'media', data: 'base64img', mimeType: 'image/png' },
|
||||
]);
|
||||
expect(resp.isError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error events', () => {
|
||||
it('emits error event for structured errors', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Error,
|
||||
value: { error: { message: 'Rate limited', status: 429 } },
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const err = result[0] as AgentEvent<'error'>;
|
||||
expect(err.status).toBe('RESOURCE_EXHAUSTED');
|
||||
expect(err.message).toBe('Rate limited');
|
||||
expect(err.fatal).toBe(true);
|
||||
});
|
||||
|
||||
it('emits error event for Error instances', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Error,
|
||||
value: { error: new Error('Something broke') },
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
const err = result[0] as AgentEvent<'error'>;
|
||||
expect(err.status).toBe('INTERNAL');
|
||||
expect(err.message).toBe('Something broke');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ModelInfo events', () => {
|
||||
it('emits agent_start and session_update when no stream started yet', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ModelInfo,
|
||||
value: 'gemini-2.5-pro',
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]?.type).toBe('agent_start');
|
||||
expect(result[1]?.type).toBe('session_update');
|
||||
const sessionUpdate = result[1] as AgentEvent<'session_update'>;
|
||||
expect(sessionUpdate.model).toBe('gemini-2.5-pro');
|
||||
expect(state.model).toBe('gemini-2.5-pro');
|
||||
expect(state.streamStartEmitted).toBe(true);
|
||||
});
|
||||
|
||||
it('emits session_update when stream already started', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ModelInfo,
|
||||
value: 'gemini-2.5-flash',
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.type).toBe('session_update');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentExecutionStopped events', () => {
|
||||
it('emits agent_end with the final stop message in data.message', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.AgentExecutionStopped,
|
||||
value: {
|
||||
reason: 'before_model',
|
||||
systemMessage: 'Stopped by hook',
|
||||
contextCleared: true,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const streamEnd = result[0] as AgentEvent<'agent_end'>;
|
||||
expect(streamEnd.type).toBe('agent_end');
|
||||
expect(streamEnd.reason).toBe('completed');
|
||||
expect(streamEnd.data).toEqual({ message: 'Stopped by hook' });
|
||||
});
|
||||
|
||||
it('uses reason when systemMessage is not set', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.AgentExecutionStopped,
|
||||
value: { reason: 'hook' },
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const streamEnd = result[0] as AgentEvent<'agent_end'>;
|
||||
expect(streamEnd.data).toEqual({ message: 'hook' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentExecutionBlocked events', () => {
|
||||
it('emits non-fatal error event (non-terminal, stream continues)', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: { reason: 'Policy violation' },
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const err = result[0] as AgentEvent<'error'>;
|
||||
expect(err.type).toBe('error');
|
||||
expect(err.fatal).toBe(false);
|
||||
expect(err._meta?.['code']).toBe('AGENT_EXECUTION_BLOCKED');
|
||||
expect(err.message).toBe('Agent execution blocked: Policy violation');
|
||||
});
|
||||
|
||||
it('uses systemMessage in the final error message when available', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: {
|
||||
reason: 'hook_blocked',
|
||||
systemMessage: 'Blocked by policy hook',
|
||||
contextCleared: true,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
const err = result[0] as AgentEvent<'error'>;
|
||||
expect(err.message).toBe(
|
||||
'Agent execution blocked: Blocked by policy hook',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoopDetected events', () => {
|
||||
it('emits a non-fatal warning error event', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.LoopDetected,
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.type).toBe('error');
|
||||
const loopWarning = result[0] as AgentEvent<'error'>;
|
||||
expect(loopWarning.fatal).toBe(false);
|
||||
expect(loopWarning.message).toBe('Loop detected, stopping execution');
|
||||
expect(loopWarning._meta?.['code']).toBe('LOOP_DETECTED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MaxSessionTurns events', () => {
|
||||
it('emits agent_end with max_turns', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.MaxSessionTurns,
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const streamEnd = result[0] as AgentEvent<'agent_end'>;
|
||||
expect(streamEnd.type).toBe('agent_end');
|
||||
expect(streamEnd.reason).toBe('max_turns');
|
||||
expect(streamEnd.data).toEqual({ code: 'MAX_TURNS_EXCEEDED' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Finished events', () => {
|
||||
it('emits usage for STOP', () => {
|
||||
state.streamStartEmitted = true;
|
||||
state.model = 'gemini-2.5-pro';
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Finished,
|
||||
value: {
|
||||
reason: FinishReason.STOP,
|
||||
usageMetadata: {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
cachedContentTokenCount: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const usage = result[0] as AgentEvent<'usage'>;
|
||||
expect(usage.model).toBe('gemini-2.5-pro');
|
||||
expect(usage.inputTokens).toBe(100);
|
||||
expect(usage.outputTokens).toBe(50);
|
||||
expect(usage.cachedTokens).toBe(10);
|
||||
});
|
||||
|
||||
it('emits nothing when no usage metadata is present', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: undefined },
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Citation events', () => {
|
||||
it('emits message with citation meta', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Citation,
|
||||
value: 'Source: example.com',
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const msg = result[0] as AgentEvent<'message'>;
|
||||
expect(msg.content).toEqual([
|
||||
{ type: 'text', text: 'Source: example.com' },
|
||||
]);
|
||||
expect(msg._meta?.['citation']).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserCancelled events', () => {
|
||||
it('emits agent_end with reason aborted', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.UserCancelled,
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const end = result[0] as AgentEvent<'agent_end'>;
|
||||
expect(end.type).toBe('agent_end');
|
||||
expect(end.reason).toBe('aborted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContextWindowWillOverflow events', () => {
|
||||
it('emits fatal error', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ContextWindowWillOverflow,
|
||||
value: {
|
||||
estimatedRequestTokenCount: 150000,
|
||||
remainingTokenCount: 10000,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const err = result[0] as AgentEvent<'error'>;
|
||||
expect(err.status).toBe('RESOURCE_EXHAUSTED');
|
||||
expect(err.fatal).toBe(true);
|
||||
expect(err.message).toContain('150000');
|
||||
expect(err.message).toContain('10000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('InvalidStream events', () => {
|
||||
it('emits fatal error', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.InvalidStream,
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const err = result[0] as AgentEvent<'error'>;
|
||||
expect(err.status).toBe('INTERNAL');
|
||||
expect(err.message).toBe('Invalid stream received from model');
|
||||
expect(err.fatal).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Events with no output', () => {
|
||||
it('returns empty for Retry', () => {
|
||||
const result = translateEvent({ type: GeminiEventType.Retry }, state);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty for ChatCompressed with null', () => {
|
||||
const result = translateEvent(
|
||||
{ type: GeminiEventType.ChatCompressed, value: null },
|
||||
state,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty for ToolCallConfirmation', () => {
|
||||
// ToolCallConfirmation is skipped in non-interactive mode (elicitations
|
||||
// are deferred to the interactive runtime adaptation).
|
||||
const event = {
|
||||
type: GeminiEventType.ToolCallConfirmation,
|
||||
value: {
|
||||
request: {
|
||||
callId: 'c1',
|
||||
name: 'tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'p1',
|
||||
},
|
||||
details: { type: 'info', title: 'Confirm', prompt: 'Confirm?' },
|
||||
},
|
||||
} as ServerGeminiStreamEvent;
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event IDs', () => {
|
||||
it('generates sequential IDs', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const e1 = translateEvent(
|
||||
{ type: GeminiEventType.Content, value: 'a' },
|
||||
state,
|
||||
);
|
||||
const e2 = translateEvent(
|
||||
{ type: GeminiEventType.Content, value: 'b' },
|
||||
state,
|
||||
);
|
||||
expect(e1[0]?.id).toBe('test-stream-0');
|
||||
expect(e2[0]?.id).toBe('test-stream-1');
|
||||
});
|
||||
|
||||
it('includes streamId in events', () => {
|
||||
const events = translateEvent(
|
||||
{ type: GeminiEventType.Content, value: 'hi' },
|
||||
state,
|
||||
);
|
||||
for (const e of events) {
|
||||
expect(e.streamId).toBe('test-stream');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapFinishReason', () => {
|
||||
it('maps STOP to completed', () => {
|
||||
expect(mapFinishReason(FinishReason.STOP)).toBe('completed');
|
||||
});
|
||||
|
||||
it('maps undefined to completed', () => {
|
||||
expect(mapFinishReason(undefined)).toBe('completed');
|
||||
});
|
||||
|
||||
it('maps MAX_TOKENS to max_budget', () => {
|
||||
expect(mapFinishReason(FinishReason.MAX_TOKENS)).toBe('max_budget');
|
||||
});
|
||||
|
||||
it('maps SAFETY to refusal', () => {
|
||||
expect(mapFinishReason(FinishReason.SAFETY)).toBe('refusal');
|
||||
});
|
||||
|
||||
it('maps MALFORMED_FUNCTION_CALL to failed', () => {
|
||||
expect(mapFinishReason(FinishReason.MALFORMED_FUNCTION_CALL)).toBe(
|
||||
'failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps RECITATION to refusal', () => {
|
||||
expect(mapFinishReason(FinishReason.RECITATION)).toBe('refusal');
|
||||
});
|
||||
|
||||
it('maps LANGUAGE to refusal', () => {
|
||||
expect(mapFinishReason(FinishReason.LANGUAGE)).toBe('refusal');
|
||||
});
|
||||
|
||||
it('maps BLOCKLIST to refusal', () => {
|
||||
expect(mapFinishReason(FinishReason.BLOCKLIST)).toBe('refusal');
|
||||
});
|
||||
|
||||
it('maps OTHER to failed', () => {
|
||||
expect(mapFinishReason(FinishReason.OTHER)).toBe('failed');
|
||||
});
|
||||
|
||||
it('maps PROHIBITED_CONTENT to refusal', () => {
|
||||
expect(mapFinishReason(FinishReason.PROHIBITED_CONTENT)).toBe('refusal');
|
||||
});
|
||||
|
||||
it('maps IMAGE_SAFETY to refusal', () => {
|
||||
expect(mapFinishReason(FinishReason.IMAGE_SAFETY)).toBe('refusal');
|
||||
});
|
||||
|
||||
it('maps IMAGE_PROHIBITED_CONTENT to refusal', () => {
|
||||
expect(mapFinishReason(FinishReason.IMAGE_PROHIBITED_CONTENT)).toBe(
|
||||
'refusal',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps UNEXPECTED_TOOL_CALL to failed', () => {
|
||||
expect(mapFinishReason(FinishReason.UNEXPECTED_TOOL_CALL)).toBe('failed');
|
||||
});
|
||||
|
||||
it('maps NO_IMAGE to failed', () => {
|
||||
expect(mapFinishReason(FinishReason.NO_IMAGE)).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapHttpToGrpcStatus', () => {
|
||||
it('maps 400 to INVALID_ARGUMENT', () => {
|
||||
expect(mapHttpToGrpcStatus(400)).toBe('INVALID_ARGUMENT');
|
||||
});
|
||||
|
||||
it('maps 401 to UNAUTHENTICATED', () => {
|
||||
expect(mapHttpToGrpcStatus(401)).toBe('UNAUTHENTICATED');
|
||||
});
|
||||
|
||||
it('maps 429 to RESOURCE_EXHAUSTED', () => {
|
||||
expect(mapHttpToGrpcStatus(429)).toBe('RESOURCE_EXHAUSTED');
|
||||
});
|
||||
|
||||
it('maps undefined to INTERNAL', () => {
|
||||
expect(mapHttpToGrpcStatus(undefined)).toBe('INTERNAL');
|
||||
});
|
||||
|
||||
it('maps unknown codes to INTERNAL', () => {
|
||||
expect(mapHttpToGrpcStatus(418)).toBe('INTERNAL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapError', () => {
|
||||
it('maps structured errors with status', () => {
|
||||
const result = mapError({ message: 'Rate limit', status: 429 });
|
||||
expect(result.status).toBe('RESOURCE_EXHAUSTED');
|
||||
expect(result.message).toBe('Rate limit');
|
||||
expect(result.fatal).toBe(true);
|
||||
expect(result._meta?.['rawError']).toEqual({
|
||||
message: 'Rate limit',
|
||||
status: 429,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps Error instances', () => {
|
||||
const result = mapError(new Error('Something failed'));
|
||||
expect(result.status).toBe('INTERNAL');
|
||||
expect(result.message).toBe('Something failed');
|
||||
});
|
||||
|
||||
it('preserves error name in _meta', () => {
|
||||
class CustomError extends Error {
|
||||
constructor(msg: string) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
const result = mapError(new CustomError('test'));
|
||||
expect(result._meta?.['errorName']).toBe('CustomError');
|
||||
});
|
||||
|
||||
it('maps non-Error values to string', () => {
|
||||
const result = mapError('raw string error');
|
||||
expect(result.message).toBe('raw string error');
|
||||
expect(result.status).toBe('INTERNAL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapUsage', () => {
|
||||
it('maps all fields', () => {
|
||||
const result = mapUsage(
|
||||
{
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
cachedContentTokenCount: 25,
|
||||
},
|
||||
'gemini-2.5-pro',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
model: 'gemini-2.5-pro',
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cachedTokens: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses "unknown" for missing model', () => {
|
||||
const result = mapUsage({});
|
||||
expect(result.model).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -1,457 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Pure, stateless-per-call translation functions that convert
|
||||
* ServerGeminiStreamEvent objects into AgentEvent objects.
|
||||
*
|
||||
* No side effects, no generators. Each call to `translateEvent` takes an event
|
||||
* and mutable TranslationState, returning zero or more AgentEvents.
|
||||
*/
|
||||
|
||||
import type { FinishReason } from '@google/genai';
|
||||
import { GeminiEventType } from '../core/turn.js';
|
||||
import type {
|
||||
ServerGeminiStreamEvent,
|
||||
StructuredError,
|
||||
GeminiFinishedEventValue,
|
||||
} from '../core/turn.js';
|
||||
import type {
|
||||
AgentEvent,
|
||||
StreamEndReason,
|
||||
ErrorData,
|
||||
Usage,
|
||||
AgentEventType,
|
||||
} from './types.js';
|
||||
import {
|
||||
geminiPartsToContentParts,
|
||||
toolResultDisplayToContentParts,
|
||||
buildToolResponseData,
|
||||
} from './content-utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Translation State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TranslationState {
|
||||
streamId: string;
|
||||
streamStartEmitted: boolean;
|
||||
model: string | undefined;
|
||||
eventCounter: number;
|
||||
/** Tracks callId → tool name from requests so responses can reference the name. */
|
||||
pendingToolNames: Map<string, string>;
|
||||
}
|
||||
|
||||
export function createTranslationState(streamId?: string): TranslationState {
|
||||
return {
|
||||
streamId: streamId ?? crypto.randomUUID(),
|
||||
streamStartEmitted: false,
|
||||
model: undefined,
|
||||
eventCounter: 0,
|
||||
pendingToolNames: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeEvent<T extends AgentEventType>(
|
||||
type: T,
|
||||
state: TranslationState,
|
||||
payload: Partial<AgentEvent<T>>,
|
||||
): AgentEvent {
|
||||
const id = `${state.streamId}-${state.eventCounter++}`;
|
||||
// TypeScript cannot preserve the specific discriminated union member across
|
||||
// this generic object assembly, so keep the narrowing local to the event
|
||||
// constructor boundary.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
...payload,
|
||||
id,
|
||||
timestamp: new Date().toISOString(),
|
||||
streamId: state.streamId,
|
||||
type,
|
||||
} as AgentEvent;
|
||||
}
|
||||
|
||||
function ensureStreamStart(state: TranslationState, out: AgentEvent[]): void {
|
||||
if (!state.streamStartEmitted) {
|
||||
out.push(makeEvent('agent_start', state, {}));
|
||||
state.streamStartEmitted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core Translator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Translates a single ServerGeminiStreamEvent into zero or more AgentEvents.
|
||||
* Mutates `state` (counter, flags) as a side effect.
|
||||
*/
|
||||
export function translateEvent(
|
||||
event: ServerGeminiStreamEvent,
|
||||
state: TranslationState,
|
||||
): AgentEvent[] {
|
||||
const out: AgentEvent[] = [];
|
||||
|
||||
switch (event.type) {
|
||||
case GeminiEventType.ModelInfo:
|
||||
state.model = event.value;
|
||||
ensureStreamStart(state, out);
|
||||
out.push(makeEvent('session_update', state, { model: event.value }));
|
||||
break;
|
||||
|
||||
case GeminiEventType.Content:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('message', state, {
|
||||
role: 'agent',
|
||||
content: [{ type: 'text', text: event.value }],
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.Thought:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('message', state, {
|
||||
role: 'agent',
|
||||
content: [{ type: 'thought', thought: event.value.description }],
|
||||
_meta: event.value.subject
|
||||
? { source: 'agent', subject: event.value.subject }
|
||||
: { source: 'agent' },
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.Citation:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('message', state, {
|
||||
role: 'agent',
|
||||
content: [{ type: 'text', text: event.value }],
|
||||
_meta: { source: 'agent', citation: true },
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.Finished:
|
||||
handleFinished(event.value, state, out);
|
||||
break;
|
||||
|
||||
case GeminiEventType.Error:
|
||||
handleError(event.value.error, state, out);
|
||||
break;
|
||||
|
||||
case GeminiEventType.UserCancelled:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('agent_end', state, {
|
||||
reason: 'aborted',
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.MaxSessionTurns:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('agent_end', state, {
|
||||
reason: 'max_turns',
|
||||
data: {
|
||||
code: 'MAX_TURNS_EXCEEDED',
|
||||
},
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.LoopDetected:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('error', state, {
|
||||
status: 'INTERNAL',
|
||||
message: 'Loop detected, stopping execution',
|
||||
fatal: false,
|
||||
_meta: { code: 'LOOP_DETECTED' },
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.ContextWindowWillOverflow:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('error', state, {
|
||||
status: 'RESOURCE_EXHAUSTED',
|
||||
message: `Context window will overflow (estimated: ${event.value.estimatedRequestTokenCount}, remaining: ${event.value.remainingTokenCount})`,
|
||||
fatal: true,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.AgentExecutionStopped:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('agent_end', state, {
|
||||
reason: 'completed',
|
||||
data: {
|
||||
message: event.value.systemMessage?.trim() || event.value.reason,
|
||||
},
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.AgentExecutionBlocked:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('error', state, {
|
||||
status: 'PERMISSION_DENIED',
|
||||
message: `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`,
|
||||
fatal: false,
|
||||
_meta: { code: 'AGENT_EXECUTION_BLOCKED' },
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.InvalidStream:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('error', state, {
|
||||
status: 'INTERNAL',
|
||||
message: 'Invalid stream received from model',
|
||||
fatal: true,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.ToolCallRequest:
|
||||
ensureStreamStart(state, out);
|
||||
state.pendingToolNames.set(event.value.callId, event.value.name);
|
||||
out.push(
|
||||
makeEvent('tool_request', state, {
|
||||
requestId: event.value.callId,
|
||||
name: event.value.name,
|
||||
args: event.value.args,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.ToolCallResponse: {
|
||||
ensureStreamStart(state, out);
|
||||
const displayContent = toolResultDisplayToContentParts(
|
||||
event.value.resultDisplay,
|
||||
);
|
||||
const data = buildToolResponseData(event.value);
|
||||
out.push(
|
||||
makeEvent('tool_response', state, {
|
||||
requestId: event.value.callId,
|
||||
name: state.pendingToolNames.get(event.value.callId) ?? 'unknown',
|
||||
content: event.value.error
|
||||
? [{ type: 'text', text: event.value.error.message }]
|
||||
: geminiPartsToContentParts(event.value.responseParts),
|
||||
isError: event.value.error !== undefined,
|
||||
...(displayContent ? { displayContent } : {}),
|
||||
...(data ? { data } : {}),
|
||||
}),
|
||||
);
|
||||
state.pendingToolNames.delete(event.value.callId);
|
||||
break;
|
||||
}
|
||||
|
||||
case GeminiEventType.ToolCallConfirmation:
|
||||
// Elicitations are handled separately by the session layer
|
||||
break;
|
||||
|
||||
// Internal concerns — no AgentEvent emitted
|
||||
case GeminiEventType.ChatCompressed:
|
||||
case GeminiEventType.Retry:
|
||||
break;
|
||||
|
||||
default:
|
||||
((x: never) => {
|
||||
throw new Error(`Unhandled event type: ${JSON.stringify(x)}`);
|
||||
})(event);
|
||||
break;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Finished Event Handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleFinished(
|
||||
value: GeminiFinishedEventValue,
|
||||
state: TranslationState,
|
||||
out: AgentEvent[],
|
||||
): void {
|
||||
if (value.usageMetadata) {
|
||||
ensureStreamStart(state, out);
|
||||
const usage = mapUsage(value.usageMetadata, state.model);
|
||||
out.push(makeEvent('usage', state, usage));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error Handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleError(
|
||||
error: unknown,
|
||||
state: TranslationState,
|
||||
out: AgentEvent[],
|
||||
): void {
|
||||
ensureStreamStart(state, out);
|
||||
|
||||
const mapped = mapError(error);
|
||||
out.push(makeEvent('error', state, mapped));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public Mapping Functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Maps a Gemini FinishReason to an AgentEnd reason.
|
||||
*/
|
||||
export function mapFinishReason(
|
||||
reason: FinishReason | undefined,
|
||||
): StreamEndReason {
|
||||
if (!reason) return 'completed';
|
||||
|
||||
switch (reason) {
|
||||
case 'STOP':
|
||||
case 'FINISH_REASON_UNSPECIFIED':
|
||||
return 'completed';
|
||||
case 'MAX_TOKENS':
|
||||
return 'max_budget';
|
||||
case 'SAFETY':
|
||||
case 'RECITATION':
|
||||
case 'LANGUAGE':
|
||||
case 'BLOCKLIST':
|
||||
case 'PROHIBITED_CONTENT':
|
||||
case 'SPII':
|
||||
case 'IMAGE_SAFETY':
|
||||
case 'IMAGE_PROHIBITED_CONTENT':
|
||||
return 'refusal';
|
||||
case 'MALFORMED_FUNCTION_CALL':
|
||||
case 'OTHER':
|
||||
case 'UNEXPECTED_TOOL_CALL':
|
||||
case 'NO_IMAGE':
|
||||
return 'failed';
|
||||
default:
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an HTTP status code to a gRPC-style status string.
|
||||
*/
|
||||
export function mapHttpToGrpcStatus(
|
||||
httpStatus: number | undefined,
|
||||
): ErrorData['status'] {
|
||||
if (httpStatus === undefined) return 'INTERNAL';
|
||||
|
||||
switch (httpStatus) {
|
||||
case 400:
|
||||
return 'INVALID_ARGUMENT';
|
||||
case 401:
|
||||
return 'UNAUTHENTICATED';
|
||||
case 403:
|
||||
return 'PERMISSION_DENIED';
|
||||
case 404:
|
||||
return 'NOT_FOUND';
|
||||
case 409:
|
||||
return 'ALREADY_EXISTS';
|
||||
case 429:
|
||||
return 'RESOURCE_EXHAUSTED';
|
||||
case 500:
|
||||
return 'INTERNAL';
|
||||
case 501:
|
||||
return 'UNIMPLEMENTED';
|
||||
case 503:
|
||||
return 'UNAVAILABLE';
|
||||
case 504:
|
||||
return 'DEADLINE_EXCEEDED';
|
||||
default:
|
||||
return 'INTERNAL';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a StructuredError (or unknown error value) to an ErrorData payload.
|
||||
* Preserves selected error metadata in _meta and includes raw structured
|
||||
* errors for lossless debugging.
|
||||
*/
|
||||
export function mapError(
|
||||
error: unknown,
|
||||
): ErrorData & { _meta?: Record<string, unknown> } {
|
||||
const meta: Record<string, unknown> = {};
|
||||
|
||||
if (error instanceof Error) {
|
||||
meta['errorName'] = error.constructor.name;
|
||||
if ('exitCode' in error && typeof error.exitCode === 'number') {
|
||||
meta['exitCode'] = error.exitCode;
|
||||
}
|
||||
if ('code' in error) {
|
||||
meta['code'] = error.code;
|
||||
}
|
||||
}
|
||||
|
||||
if (isStructuredError(error)) {
|
||||
const structuredMeta = { ...meta, rawError: error };
|
||||
return {
|
||||
status: mapHttpToGrpcStatus(error.status),
|
||||
message: error.message,
|
||||
fatal: true,
|
||||
_meta: structuredMeta,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
status: 'INTERNAL',
|
||||
message: error.message,
|
||||
fatal: true,
|
||||
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'INTERNAL',
|
||||
message: String(error),
|
||||
fatal: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isStructuredError(error: unknown): error is StructuredError {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'message' in error &&
|
||||
typeof error.message === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps Gemini usageMetadata to Usage.
|
||||
*/
|
||||
export function mapUsage(
|
||||
metadata: {
|
||||
promptTokenCount?: number;
|
||||
candidatesTokenCount?: number;
|
||||
cachedContentTokenCount?: number;
|
||||
},
|
||||
model?: string,
|
||||
): Usage {
|
||||
return {
|
||||
model: model ?? 'unknown',
|
||||
inputTokens: metadata.promptTokenCount,
|
||||
outputTokens: metadata.candidatesTokenCount,
|
||||
cachedTokens: metadata.cachedContentTokenCount,
|
||||
};
|
||||
}
|
||||
@@ -86,7 +86,6 @@ export class MockAgentProtocol implements AgentProtocol {
|
||||
) {
|
||||
const now = new Date().toISOString();
|
||||
for (const eventData of events) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const event: AgentEvent = {
|
||||
...eventData,
|
||||
id: eventData.id ?? `e-${this._nextEventId++}`,
|
||||
@@ -127,7 +126,6 @@ export class MockAgentProtocol implements AgentProtocol {
|
||||
|
||||
// Helper to normalize and prepare for emission
|
||||
const normalize = (eventData: MockAgentEvent): AgentEvent =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
({
|
||||
...eventData,
|
||||
id: eventData.id ?? `e-${this._nextEventId++}`,
|
||||
|
||||
@@ -81,18 +81,9 @@ export type AgentEventData<
|
||||
EventType extends keyof AgentEvents = keyof AgentEvents,
|
||||
> = AgentEvents[EventType] & { type: EventType };
|
||||
|
||||
/**
|
||||
* Mapped type that produces a proper discriminated union when `EventType` is
|
||||
* the default (all keys), enabling `switch (event.type)` narrowing.
|
||||
* When a specific EventType is provided, resolves to a single variant.
|
||||
*/
|
||||
export type AgentEvent<
|
||||
EventType extends keyof AgentEvents = keyof AgentEvents,
|
||||
> = {
|
||||
[K in EventType]: AgentEventCommon & AgentEvents[K] & { type: K };
|
||||
}[EventType];
|
||||
|
||||
export type AgentEventType = keyof AgentEvents;
|
||||
> = AgentEventCommon & AgentEventData<EventType>;
|
||||
|
||||
export interface AgentEvents {
|
||||
/** MUST be the first event emitted in a session. */
|
||||
@@ -272,7 +263,7 @@ export interface AgentStart {
|
||||
streamId: string;
|
||||
}
|
||||
|
||||
export type StreamEndReason =
|
||||
type StreamEndReason =
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'aborted'
|
||||
|
||||
@@ -11,10 +11,8 @@ import {
|
||||
} from './browserAgentFactory.js';
|
||||
import { injectAutomationOverlay } from './automationOverlay.js';
|
||||
import { makeFakeConfig } from '../../test-utils/config.js';
|
||||
import { PolicyDecision, PRIORITY_SUBAGENT_TOOL } from '../../policy/types.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
|
||||
import type { PolicyEngine } from '../../policy/policy-engine.js';
|
||||
import type { BrowserManager } from './browserManager.js';
|
||||
|
||||
// Create mock browser manager
|
||||
@@ -302,116 +300,6 @@ describe('browserAgentFactory', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Policy Registration', () => {
|
||||
let mockPolicyEngine: {
|
||||
addRule: ReturnType<typeof vi.fn>;
|
||||
hasRuleForTool: ReturnType<typeof vi.fn>;
|
||||
removeRulesForTool: ReturnType<typeof vi.fn>;
|
||||
getRules: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPolicyEngine = {
|
||||
addRule: vi.fn(),
|
||||
hasRuleForTool: vi.fn().mockReturnValue(false),
|
||||
removeRulesForTool: vi.fn(),
|
||||
getRules: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
vi.spyOn(mockConfig, 'getPolicyEngine').mockReturnValue(
|
||||
mockPolicyEngine as unknown as PolicyEngine,
|
||||
);
|
||||
});
|
||||
|
||||
it('should register sensitive action rules', async () => {
|
||||
mockConfig = makeFakeConfig({
|
||||
agents: {
|
||||
browser: {
|
||||
confirmSensitiveActions: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.spyOn(mockConfig, 'getPolicyEngine').mockReturnValue(
|
||||
mockPolicyEngine as unknown as PolicyEngine,
|
||||
);
|
||||
|
||||
await createBrowserAgentDefinition(mockConfig, mockMessageBus);
|
||||
|
||||
expect(mockPolicyEngine.addRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolName: 'mcp_browser_agent_fill',
|
||||
decision: PolicyDecision.ASK_USER,
|
||||
priority: 999,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockPolicyEngine.addRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolName: 'mcp_browser_agent_upload_file',
|
||||
decision: PolicyDecision.ASK_USER,
|
||||
priority: 999,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockPolicyEngine.addRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolName: 'mcp_browser_agent_evaluate_script',
|
||||
decision: PolicyDecision.ASK_USER,
|
||||
priority: 999,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should register fill rule even when confirmSensitiveActions is disabled', async () => {
|
||||
await createBrowserAgentDefinition(mockConfig, mockMessageBus);
|
||||
|
||||
expect(mockPolicyEngine.addRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolName: 'mcp_browser_agent_fill',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockPolicyEngine.addRule).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolName: 'mcp_browser_agent_upload_file',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should register ALLOW rules for read-only tools', async () => {
|
||||
mockBrowserManager.getDiscoveredTools.mockResolvedValue([
|
||||
{ name: 'take_snapshot', description: 'Take snapshot' },
|
||||
{ name: 'take_screenshot', description: 'Take screenshot' },
|
||||
{ name: 'list_pages', description: 'list all pages' },
|
||||
]);
|
||||
|
||||
await createBrowserAgentDefinition(mockConfig, mockMessageBus);
|
||||
|
||||
expect(mockPolicyEngine.addRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolName: 'mcp_browser_agent_take_snapshot',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: PRIORITY_SUBAGENT_TOOL,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockPolicyEngine.addRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolName: 'mcp_browser_agent_take_screenshot',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: PRIORITY_SUBAGENT_TOOL,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockPolicyEngine.addRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolName: 'mcp_browser_agent_list_pages',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: PRIORITY_SUBAGENT_TOOL,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupBrowserAgent', () => {
|
||||
it('should call close on browser manager', async () => {
|
||||
await cleanupBrowserAgent(
|
||||
|
||||
@@ -21,8 +21,6 @@ import type { LocalAgentDefinition } from '../types.js';
|
||||
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
|
||||
import type { AnyDeclarativeTool } from '../../tools/tools.js';
|
||||
import { BrowserManager } from './browserManager.js';
|
||||
import { BROWSER_AGENT_NAME } from './browserAgentDefinition.js';
|
||||
import { MCP_TOOL_PREFIX } from '../../tools/mcp-tool.js';
|
||||
import {
|
||||
BrowserAgentDefinition,
|
||||
type BrowserTaskResultSchema,
|
||||
@@ -32,11 +30,6 @@ import { createAnalyzeScreenshotTool } from './analyzeScreenshot.js';
|
||||
import { injectAutomationOverlay } from './automationOverlay.js';
|
||||
import { injectInputBlocker } from './inputBlocker.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import {
|
||||
PolicyDecision,
|
||||
PRIORITY_SUBAGENT_TOOL,
|
||||
type PolicyRule,
|
||||
} from '../../policy/types.js';
|
||||
|
||||
/**
|
||||
* Creates a browser agent definition with MCP tools configured.
|
||||
@@ -93,79 +86,9 @@ export async function createBrowserAgentDefinition(
|
||||
browserManager,
|
||||
messageBus,
|
||||
shouldDisableInput,
|
||||
browserConfig.customConfig.blockFileUploads,
|
||||
);
|
||||
const availableToolNames = mcpTools.map((t) => t.name);
|
||||
|
||||
// Register high-priority policy rules for sensitive actions which is not
|
||||
// able to be overwrite by YOLO mode.
|
||||
const policyEngine = config.getPolicyEngine();
|
||||
|
||||
if (policyEngine) {
|
||||
const existingRules = policyEngine.getRules();
|
||||
|
||||
const restrictedTools = ['fill', 'fill_form'];
|
||||
|
||||
// ASK_USER for upload_file and evaluate_script when sensitive action
|
||||
// need confirmation.
|
||||
if (browserConfig.customConfig.confirmSensitiveActions) {
|
||||
restrictedTools.push('upload_file', 'evaluate_script');
|
||||
}
|
||||
|
||||
for (const toolName of restrictedTools) {
|
||||
const rule = generateAskUserRules(toolName);
|
||||
if (!existingRules.some((r) => isRuleEqual(r, rule))) {
|
||||
policyEngine.addRule(rule);
|
||||
}
|
||||
}
|
||||
|
||||
// Reduce noise for read-only tools in default mode
|
||||
const readOnlyTools = [
|
||||
'take_snapshot',
|
||||
'take_screenshot',
|
||||
'list_pages',
|
||||
'list_network_requests',
|
||||
];
|
||||
for (const toolName of readOnlyTools) {
|
||||
if (availableToolNames.includes(toolName)) {
|
||||
const rule = generateAllowRules(toolName);
|
||||
if (!existingRules.some((r) => isRuleEqual(r, rule))) {
|
||||
policyEngine.addRule(rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generateAskUserRules(toolName: string): PolicyRule {
|
||||
return {
|
||||
toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`,
|
||||
decision: PolicyDecision.ASK_USER,
|
||||
priority: 999,
|
||||
source: 'BrowserAgent (Sensitive Actions)',
|
||||
mcpName: BROWSER_AGENT_NAME,
|
||||
};
|
||||
}
|
||||
|
||||
function generateAllowRules(toolName: string): PolicyRule {
|
||||
return {
|
||||
toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: PRIORITY_SUBAGENT_TOOL,
|
||||
source: 'BrowserAgent (Read-Only)',
|
||||
mcpName: BROWSER_AGENT_NAME,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if policy rule the same in all the attributes that we care about
|
||||
function isRuleEqual(rule1: PolicyRule, rule2: PolicyRule) {
|
||||
return (
|
||||
rule1.toolName === rule2.toolName &&
|
||||
rule1.decision === rule2.decision &&
|
||||
rule1.priority === rule2.priority &&
|
||||
rule1.mcpName === rule2.mcpName
|
||||
);
|
||||
}
|
||||
|
||||
// Validate required semantic tools are available
|
||||
const requiredSemanticTools = [
|
||||
'click',
|
||||
|
||||
@@ -9,7 +9,6 @@ import { BrowserManager } from './browserManager.js';
|
||||
import { makeFakeConfig } from '../../test-utils/config.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import { injectAutomationOverlay } from './automationOverlay.js';
|
||||
import { coreEvents } from '../../utils/events.js';
|
||||
|
||||
// Mock the MCP SDK
|
||||
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
||||
@@ -78,7 +77,6 @@ describe('BrowserManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(injectAutomationOverlay).mockClear();
|
||||
vi.spyOn(coreEvents, 'emitFeedback').mockImplementation(() => {});
|
||||
|
||||
// Re-establish consent mock after resetAllMocks
|
||||
vi.mocked(getBrowserConsentIfNeeded).mockResolvedValue(true);
|
||||
@@ -429,11 +427,6 @@ describe('BrowserManager', () => {
|
||||
?.args as string[];
|
||||
expect(args).toContain('--autoConnect');
|
||||
expect(args).not.toContain('--isolated');
|
||||
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'info',
|
||||
expect.stringContaining('saved logins will be visible'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw actionable error when existing mode connection fails', async () => {
|
||||
|
||||