mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 05:31:02 -07:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 042bd7fd40 | |||
| 27f35c3358 | |||
| fd481ffc25 | |||
| 1332e110e3 | |||
| 3cff2154c0 | |||
| 8b92be573c |
@@ -0,0 +1,149 @@
|
||||
# Codebase understanding
|
||||
|
||||
This document provides a deep-dive technical overview of the Gemini CLI
|
||||
architecture. It is designed for developers who need to understand the
|
||||
system's inner workings, from startup to advanced autonomous behaviors.
|
||||
|
||||
## Repository architecture
|
||||
|
||||
Gemini CLI is a monorepo structured to maintain a strict separation between
|
||||
the user interface and the agent's core reasoning logic.
|
||||
|
||||
- **`packages/cli`**: The Terminal User Interface (TUI). Built with React and
|
||||
Ink, it manages the interactive terminal experience, including keyboard
|
||||
protocols, rendering, and terminal state management.
|
||||
- **`packages/core`**: The UI-agnostic engine. It contains the primary
|
||||
orchestration logic, model routing, tool systems, policy enforcement, and
|
||||
Gemini API communication.
|
||||
- **`packages/devtools`**: A suite for real-time inspection of network traffic,
|
||||
console logs, and session activity.
|
||||
- **`packages/sdk`**: A library for developers to build third-party tools and
|
||||
extensions.
|
||||
- **`packages/vscode-ide-companion`**: A specialized bridge that feeds real-time
|
||||
editor state (open files, active selections, cursor positions) to the agent.
|
||||
|
||||
---
|
||||
|
||||
## 1. Application lifecycle
|
||||
|
||||
### Startup and initialization
|
||||
The entry point is `packages/cli/src/gemini.tsx`. The startup sequence is
|
||||
designed for security and resilience:
|
||||
|
||||
1. **I/O redirection**: Standard output streams (`stdout`, `stderr`) are
|
||||
patched to capture all logs and errors. This allows the CLI to redirect
|
||||
diagnostic information to the TUI's debug console or a remote DevTools server
|
||||
without corrupting the user's terminal interface.
|
||||
2. **Memory-aware relaunch**: The CLI checks the host system's total memory.
|
||||
If it detects that Node.js's default heap limit is insufficient for complex
|
||||
codebase analysis, it re-launches itself using the
|
||||
`--max-old-space-size` flag, targeting approximately 50% of system memory.
|
||||
3. **Sandboxing**: If configured, the CLI launches a restricted "sandbox"
|
||||
environment (using Docker, Podman, or a localized process) to isolate the
|
||||
agent's autonomous actions from the host system.
|
||||
4. **Interactive (TUI) vs. Non-interactive (CLI)**:
|
||||
- **Interactive mode**: Initializes the Ink renderer, starting a persistent
|
||||
React application that manages terminal state via providers.
|
||||
- **Non-interactive mode**: Executes a streamlined loop in
|
||||
`nonInteractiveCli.ts`, designed for single prompts or piped input/output
|
||||
redirection.
|
||||
|
||||
---
|
||||
|
||||
## 2. Model routing and selection
|
||||
|
||||
The `ModelRouterService` (`packages/core/src/routing`) implements a
|
||||
"Composite Strategy" to select the optimal model for every request.
|
||||
|
||||
### Routing strategies
|
||||
- **classifier**: Uses a lightweight LLM call to categorize the complexity of a
|
||||
task based on a rubric (Strategic Planning, Multi-step Coordination,
|
||||
Ambiguity). It chooses between a "Pro" model (for complex reasoning) and a
|
||||
"Flash" model (for simple operations).
|
||||
- **approvalMode**: Selects specialized models (like `gemini-2.0-flash-lite`)
|
||||
when the agent is in specific modes like `Plan Mode`.
|
||||
- **numericalClassifier**: A deterministic strategy that selects models based
|
||||
on the number of tokens in the conversation or the length of the history.
|
||||
- **fallback**: Automatically switches models if the primary model encounters
|
||||
quota limits (429) or transient API failures.
|
||||
|
||||
---
|
||||
|
||||
## 3. Intelligent context management
|
||||
|
||||
The agent maintains deep project awareness while staying within token limits
|
||||
through several services in `packages/core/src/services`:
|
||||
|
||||
### ChatCompressionService
|
||||
Triggered when the history exceeds 50% of the model's context window:
|
||||
1. **State snapshots**: The agent generates a structured `<state_snapshot>`
|
||||
representing the cumulative knowledge of the session (constraints, progress,
|
||||
paths).
|
||||
2. **The "Probe" (Self-Correction)**: A second LLM pass compares the summary
|
||||
against the original history to ensure no critical technical details or
|
||||
user-defined constraints were lost, correcting the summary before purging
|
||||
the history.
|
||||
|
||||
### ToolOutputMaskingService
|
||||
Prevents bulky data (like large shell outputs or file reads) from clogging the
|
||||
context window. It replaces large `functionResponse` blocks with concise
|
||||
summaries and persists the full data to temporary files, allowing the agent to
|
||||
refer to the full data only when necessary.
|
||||
|
||||
---
|
||||
|
||||
## 4. Advanced tool execution and scheduling
|
||||
|
||||
The `Scheduler` (`packages/core/src/scheduler`) is an event-driven state
|
||||
machine that manages the lifecycle of autonomous actions.
|
||||
|
||||
### Lifecycle states
|
||||
`Validating` → `AwaitingApproval` → `Scheduled` → `Executing` → `Success`/`Error`
|
||||
|
||||
### Key features
|
||||
- **Policy Engine**: A granular system that evaluates tools based on security
|
||||
policies (e.g., "Allow read-only tools", "Ask for shell commands"). It can be
|
||||
configured at the project or user level.
|
||||
- **Tail calls**: Allows a tool to "link" to another action. For example, a
|
||||
shell command that produces an error can automatically trigger a "diagnostic"
|
||||
tool without returning control to the main model.
|
||||
- **Parallelism**: The scheduler executes independent read-only tools in
|
||||
parallel while enforcing sequential execution for tools that modify the
|
||||
environment.
|
||||
- **MCP integration**: Dynamically loads tools from Model Context Protocol
|
||||
servers, integrating them seamlessly into the same policy and scheduler
|
||||
framework.
|
||||
|
||||
---
|
||||
|
||||
## 5. UI and terminal integration
|
||||
|
||||
The `packages/cli/src/ui` directory implements a sophisticated React-based TUI.
|
||||
|
||||
### Keyboard and protocols
|
||||
- **KeypressProvider**: Manages terminal input, supporting complex key
|
||||
combinations and shortcuts.
|
||||
- **Kitty keyboard protocol**: Detects terminals that support the Kitty
|
||||
protocol to enable advanced features like detecting `ctrl+enter` vs `enter`.
|
||||
- **Vim mode**: A dedicated provider that enables Vim-like navigation (hjkl,
|
||||
words, search) for both conversation history and input fields.
|
||||
|
||||
### Layout and rendering
|
||||
- **ResizeObserver**: A custom implementation that watches the terminal size
|
||||
to ensure components (like multi-column layouts or wide tables) adapt
|
||||
instantly.
|
||||
- **ConsolePatcher**: Intercepts `console.log`, `console.warn`, and
|
||||
`console.error`, routing them to the internal debug console (toggled with
|
||||
`ctrl+d`) or the external DevTools server.
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing and validation
|
||||
|
||||
Gemini CLI uses a tiered testing strategy to ensure reliability:
|
||||
1. **Unit tests**: Located alongside the source (`*.test.ts`), providing fast
|
||||
coverage for core logic.
|
||||
2. **Integration tests**: Located in `integration-tests/`, running the
|
||||
full CLI against mock and real Gemini API endpoints.
|
||||
3. **Evals**: Performance benchmarks in `evals/` that measure the agent's
|
||||
reasoning accuracy and tool-use efficiency over time.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Gemini CLI - Codebase Understanding
|
||||
|
||||
Gemini CLI is an open-source AI agent designed to let you interact with Google's
|
||||
Gemini models directly from your terminal. It's built as a **TypeScript
|
||||
monorepo** (using npm workspaces) and relies heavily on **Node.js**, **React**,
|
||||
and **Ink** (a library that lets you build terminal UIs using React components).
|
||||
|
||||
Here is a high-level walkthrough of the repository to help you understand how
|
||||
all the pieces fit together.
|
||||
|
||||
## 1. High-Level Architecture (The `packages/` Directory)
|
||||
|
||||
The project is split into several focused packages to maintain a clean
|
||||
separation of concerns:
|
||||
|
||||
- **`packages/cli`** (The Frontend)
|
||||
- This is the user-facing terminal UI.
|
||||
- It uses React + Ink. This means the terminal layout, styling, and
|
||||
interactions are managed like a modern web app (with hooks, contexts, and
|
||||
components).
|
||||
- It handles all the terminal-specific logic like key bindings, processing
|
||||
mouse/keyboard events, and rendering the chat stream or tool progress
|
||||
indicators.
|
||||
- **`packages/core`** (The Brain/Backend)
|
||||
- This is where the actual "agentic" logic lives. It is entirely UI-agnostic.
|
||||
- Contains the core looping mechanism that communicates with the Gemini API,
|
||||
maintains conversation history, compresses context, and evaluates whether
|
||||
the agent needs to invoke a tool.
|
||||
- Houses the **Tool Registry** (file system tools, shell runner, web tools)
|
||||
and the **Policy Engine** (deciding if a tool is safe to run automatically
|
||||
or needs your permission).
|
||||
- **`packages/devtools`**
|
||||
- A Chrome DevTools-like web server that runs locally! If you enable
|
||||
`general.devtools` in your settings, you can inspect network requests, agent
|
||||
thoughts, and console logs in a local browser, just like you would for a web
|
||||
app.
|
||||
- **`packages/vscode-ide-companion`**
|
||||
- A VS Code extension that pairs dynamically with the CLI. It allows the
|
||||
terminal agent to "read" your active editor state, seamlessly pulling
|
||||
context on exactly what files or lines of code you currently have
|
||||
highlighted in VS Code.
|
||||
- **`packages/sdk`**
|
||||
- Provides libraries and types so people can build custom MCP (Model Context
|
||||
Protocol) extensions or tools for the CLI.
|
||||
- **`packages/a2a-server`**
|
||||
- An experimental Agent-to-Agent server, hinting at future capabilities for
|
||||
having different agents talk to each other.
|
||||
|
||||
## 2. The Core Application Lifecycle
|
||||
|
||||
When you type `gemini` in your terminal, here's roughly what happens under the
|
||||
hood:
|
||||
|
||||
1. **Bootstrapping (`packages/cli/src/gemini.tsx`)**: The CLI loads user
|
||||
configurations, parses command-line arguments, checks authentication, and
|
||||
verifies if it needs to launch itself in a controlled "sandbox" environment
|
||||
(using Docker/Podman to isolate dangerous shell tools).
|
||||
2. **Mode Resolution**: It determines if you are piping data in or running a
|
||||
single command (`nonInteractiveCli.ts`), or if you are firing up the chat
|
||||
TUI (Terminal User Interface).
|
||||
3. **The Agent Loop (`packages/core/src/core/`)**:
|
||||
- **`GeminiClient`**: The main orchestrator. It manages sessions and
|
||||
compresses chat histories using `ChatCompressionService` so you don't
|
||||
breach token limits.
|
||||
- **`GeminiChat` & `Turn`**: For every prompt you send, a `Turn` is created.
|
||||
This represents one "exchange" where the model might think, respond, and
|
||||
realize it needs to search your codebase. It streams these requests back
|
||||
in real-time.
|
||||
|
||||
## 3. The Tool System & Execution
|
||||
|
||||
The most powerful aspect of this CLI is its ability to interact with your
|
||||
environment.
|
||||
|
||||
- In `packages/core/src/tools/`, there are native TypeScript implementations for
|
||||
operations (like reading files, searching directories, or running tests).
|
||||
- When Gemini asks to use a tool, the **Scheduler**
|
||||
(`packages/core/src/scheduler/`) intercepts the request.
|
||||
- It runs the request through the **Policy Engine**
|
||||
(`packages/core/src/policy/`). Some commands (like `rm -rf`) are flagged and
|
||||
routed to a **Confirmation Bus**, which pauses execution and asks you in the
|
||||
UI: _"Do you want to allow this command?"_
|
||||
- Once approved (or auto-approved), it executes the tool, captures standard
|
||||
output/error, and pipes that text back to Gemini to continue its thought
|
||||
process.
|
||||
|
||||
## 4. Code Quality, Building, and Testing
|
||||
|
||||
- **Bundling & Running**: The project uses `esbuild` to compile everything very
|
||||
quickly. During development, you can use `npm run start` or `npm run debug`
|
||||
(which attaches a Node.js inspector).
|
||||
- **Testing (`vitest`)**: Testing is extremely rigorous here.
|
||||
- _Unit Tests:_ `npm run test` handles basic component functionality.
|
||||
- _Integration Tests:_ `npm run test:e2e` simulates an actual sandbox,
|
||||
mocking/hitting models to make sure the CLI interacts realistically.
|
||||
- _Evals (`evals/`):_ Standalone performance benchmarks where they evaluate
|
||||
how smart the CLI is at navigating codebases or using its tools
|
||||
autonomously.
|
||||
- **`npm run preflight`**: Before a PR is pushed, this massive script runs
|
||||
formatting (Prettier), linting (ESLint), type checking (TypeScript), unit
|
||||
testing, and building, ensuring nothing breaks the main branch.
|
||||
@@ -3062,6 +3062,46 @@ describe('loadCliConfig gemmaModelRouter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig offline mode', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should enable offline mode by default from schema defaults', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.isOfflineModeEnabled()).toBe(true);
|
||||
expect(config.getOfflineSettings().localModelRouting).toBe(
|
||||
'stub_default_api',
|
||||
);
|
||||
});
|
||||
|
||||
it('should load explicit offline settings from merged settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
general: {
|
||||
offline: {
|
||||
enabled: false,
|
||||
localModelRouting: 'stub_default_api',
|
||||
},
|
||||
},
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.isOfflineModeEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig fileFiltering', () => {
|
||||
const originalArgv = process.argv;
|
||||
|
||||
|
||||
@@ -982,6 +982,7 @@ export async function loadCliConfig(
|
||||
plan: settings.general?.plan?.enabled ?? true,
|
||||
tracker: settings.experimental?.taskTracker,
|
||||
directWebFetch: settings.experimental?.directWebFetch,
|
||||
offline: settings.general?.offline,
|
||||
planSettings: settings.general?.plan?.directory
|
||||
? settings.general.plan
|
||||
: (extensionPlanSettings ?? settings.general?.plan),
|
||||
|
||||
@@ -431,6 +431,31 @@ describe('SettingsSchema', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should have offline mode settings in schema', () => {
|
||||
const offline = getSettingsSchema().general.properties.offline;
|
||||
expect(offline).toBeDefined();
|
||||
expect(offline.type).toBe('object');
|
||||
expect(offline.category).toBe('General');
|
||||
expect(offline.default).toEqual({});
|
||||
expect(offline.requiresRestart).toBe(false);
|
||||
expect(offline.showInDialog).toBe(true);
|
||||
|
||||
const enabled = offline.properties.enabled;
|
||||
expect(enabled).toBeDefined();
|
||||
expect(enabled.type).toBe('boolean');
|
||||
expect(enabled.default).toBe(true);
|
||||
expect(enabled.requiresRestart).toBe(false);
|
||||
expect(enabled.showInDialog).toBe(true);
|
||||
|
||||
const localModelRouting = offline.properties.localModelRouting;
|
||||
expect(localModelRouting).toBeDefined();
|
||||
expect(localModelRouting.type).toBe('enum');
|
||||
expect(localModelRouting.default).toBe('stub_default_api');
|
||||
expect(localModelRouting.options?.map((o) => o.value)).toEqual([
|
||||
'stub_default_api',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should have hooksConfig.notifications setting in schema', () => {
|
||||
const setting = getSettingsSchema().hooksConfig?.properties.notifications;
|
||||
expect(setting).toBeDefined();
|
||||
|
||||
@@ -325,6 +325,44 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
offline: {
|
||||
type: 'object',
|
||||
label: 'Offline Mode',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description:
|
||||
'Offline mode settings. Routes work locally by default and delegates complex tasks through a cloud subagent with confirmation.',
|
||||
showInDialog: true,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Offline Mode',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Enable offline mode behavior by default (local-first strategy with explicit cloud delegation).',
|
||||
showInDialog: true,
|
||||
},
|
||||
localModelRouting: {
|
||||
type: 'enum',
|
||||
label: 'Offline Local Model Routing',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: 'stub_default_api',
|
||||
description:
|
||||
'Selects the offline local-model routing strategy. The current stub still routes through the default API backend.',
|
||||
showInDialog: false,
|
||||
options: [
|
||||
{
|
||||
value: 'stub_default_api',
|
||||
label: 'Stub (Default API)',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
retryFetchErrors: {
|
||||
type: 'boolean',
|
||||
label: 'Retry Fetch Errors',
|
||||
|
||||
@@ -101,6 +101,9 @@ vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} }));
|
||||
vi.mock('../ui/commands/modelCommand.js', () => ({
|
||||
modelCommand: { name: 'model' },
|
||||
}));
|
||||
vi.mock('../ui/commands/offlineCommand.js', () => ({
|
||||
offlineCommand: { name: 'offline' },
|
||||
}));
|
||||
vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: {} }));
|
||||
vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {} }));
|
||||
vi.mock('../ui/commands/resumeCommand.js', () => ({
|
||||
@@ -247,6 +250,9 @@ describe('BuiltinCommandLoader', () => {
|
||||
|
||||
const mcpCmd = commands.find((c) => c.name === 'mcp');
|
||||
expect(mcpCmd).toBeDefined();
|
||||
|
||||
const offlineCmd = commands.find((c) => c.name === 'offline');
|
||||
expect(offlineCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include permissions command when folder trust is enabled', async () => {
|
||||
|
||||
@@ -43,6 +43,7 @@ import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
import { oncallCommand } from '../ui/commands/oncallCommand.js';
|
||||
import { offlineCommand } from '../ui/commands/offlineCommand.js';
|
||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||
import { planCommand } from '../ui/commands/planCommand.js';
|
||||
import { policiesCommand } from '../ui/commands/policiesCommand.js';
|
||||
@@ -183,6 +184,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
: [mcpCommand]),
|
||||
memoryCommand,
|
||||
modelCommand,
|
||||
offlineCommand,
|
||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||
...(this.config?.isPlanEnabled() ? [planCommand] : []),
|
||||
policiesCommand,
|
||||
|
||||
@@ -433,6 +433,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
);
|
||||
|
||||
const [currentModel, setCurrentModel] = useState(config.getModel());
|
||||
const [isOfflineMode, setIsOfflineMode] = useState(
|
||||
config.isOfflineModeEnabled(),
|
||||
);
|
||||
const [cloudSubagentActive, setCloudSubagentActive] = useState(false);
|
||||
|
||||
const [userTier, setUserTier] = useState<UserTierId | undefined>(undefined);
|
||||
const [quotaStats, setQuotaStats] = useState<QuotaStats | undefined>(() => {
|
||||
@@ -567,6 +571,14 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const handleModelChanged = () => {
|
||||
setCurrentModel(config.getModel());
|
||||
};
|
||||
const handleOfflineModeChanged = (payload: { enabled: boolean }) => {
|
||||
setIsOfflineMode(payload.enabled);
|
||||
};
|
||||
const handleCloudSubagentExecution = (payload: {
|
||||
state: 'started' | 'ended';
|
||||
}) => {
|
||||
setCloudSubagentActive(payload.state === 'started');
|
||||
};
|
||||
|
||||
const handleQuotaChanged = (payload: {
|
||||
remaining: number | undefined;
|
||||
@@ -581,9 +593,19 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
};
|
||||
|
||||
coreEvents.on(CoreEvent.ModelChanged, handleModelChanged);
|
||||
coreEvents.on(CoreEvent.OfflineModeChanged, handleOfflineModeChanged);
|
||||
coreEvents.on(
|
||||
CoreEvent.CloudSubagentExecution,
|
||||
handleCloudSubagentExecution,
|
||||
);
|
||||
coreEvents.on(CoreEvent.QuotaChanged, handleQuotaChanged);
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.ModelChanged, handleModelChanged);
|
||||
coreEvents.off(CoreEvent.OfflineModeChanged, handleOfflineModeChanged);
|
||||
coreEvents.off(
|
||||
CoreEvent.CloudSubagentExecution,
|
||||
handleCloudSubagentExecution,
|
||||
);
|
||||
coreEvents.off(CoreEvent.QuotaChanged, handleQuotaChanged);
|
||||
};
|
||||
}, [config]);
|
||||
@@ -2493,6 +2515,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
queueErrorMessage,
|
||||
showApprovalModeIndicator,
|
||||
allowPlanMode,
|
||||
isOfflineMode,
|
||||
cloudSubagentActive,
|
||||
currentModel,
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
@@ -2604,6 +2628,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
queueErrorMessage,
|
||||
showApprovalModeIndicator,
|
||||
allowPlanMode,
|
||||
isOfflineMode,
|
||||
cloudSubagentActive,
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { offlineCommand } from './offlineCommand.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import type { CommandContext } from './types.js';
|
||||
|
||||
describe('offlineCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockConfig = {
|
||||
isOfflineModeEnabled: vi.fn().mockReturnValue(true),
|
||||
getOfflineSettings: vi.fn().mockReturnValue({
|
||||
enabled: true,
|
||||
localModelRouting: 'stub_default_api',
|
||||
}),
|
||||
setOfflineMode: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
mockContext = {
|
||||
services: {
|
||||
agentContext: {
|
||||
config: mockConfig,
|
||||
},
|
||||
settings: {
|
||||
setValue: vi.fn(),
|
||||
},
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
});
|
||||
|
||||
it('shows offline mode status', async () => {
|
||||
if (!offlineCommand.action) {
|
||||
throw new Error('offline command must have an action');
|
||||
}
|
||||
const result = await offlineCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
content: expect.stringContaining('Offline mode is enabled'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('enables offline mode with /offline on', async () => {
|
||||
const onCommand = offlineCommand.subCommands?.find((c) => c.name === 'on');
|
||||
if (!onCommand?.action) {
|
||||
throw new Error('/offline on command must have an action');
|
||||
}
|
||||
|
||||
const result = await onCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'general.offline.enabled',
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
mockContext.services.agentContext?.config.setOfflineMode,
|
||||
).toHaveBeenCalledWith(true);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Offline mode enabled.',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('disables offline mode with /offline off', async () => {
|
||||
const offCommand = offlineCommand.subCommands?.find(
|
||||
(c) => c.name === 'off',
|
||||
);
|
||||
if (!offCommand?.action) {
|
||||
throw new Error('/offline off command must have an action');
|
||||
}
|
||||
|
||||
const result = await offCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'general.offline.enabled',
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
mockContext.services.agentContext?.config.setOfflineMode,
|
||||
).toHaveBeenCalledWith(false);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Offline mode disabled.',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import {
|
||||
CommandKind,
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
} from './types.js';
|
||||
|
||||
function getStatusMessage(context: CommandContext): string {
|
||||
const config = context.services.agentContext?.config;
|
||||
if (!config) {
|
||||
return 'Offline mode status is unavailable because config is not loaded.';
|
||||
}
|
||||
|
||||
const status = config.isOfflineModeEnabled() ? 'enabled' : 'disabled';
|
||||
const offlineSettings = config.getOfflineSettings();
|
||||
|
||||
return `Offline mode is ${status}. Local routing: ${offlineSettings.localModelRouting}. Cloud delegation subagent: cloud-subagent (tool: cloud_subagent).`;
|
||||
}
|
||||
|
||||
async function setOfflineMode(
|
||||
context: CommandContext,
|
||||
enabled: boolean,
|
||||
): Promise<string> {
|
||||
const config = context.services.agentContext?.config;
|
||||
if (!config) {
|
||||
return 'Offline mode could not be changed because config is not loaded.';
|
||||
}
|
||||
|
||||
context.services.settings.setValue(
|
||||
SettingScope.User,
|
||||
'general.offline.enabled',
|
||||
enabled,
|
||||
);
|
||||
await config.setOfflineMode(enabled);
|
||||
|
||||
const status = enabled ? 'enabled' : 'disabled';
|
||||
return `Offline mode ${status}.`;
|
||||
}
|
||||
|
||||
const statusCommand: SlashCommand = {
|
||||
name: 'status',
|
||||
description: 'Show current offline mode status',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: async (context) => ({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: getStatusMessage(context),
|
||||
}),
|
||||
};
|
||||
|
||||
const enableCommand: SlashCommand = {
|
||||
name: 'on',
|
||||
altNames: ['enable'],
|
||||
description: 'Enable offline mode',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: async (context) => ({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: await setOfflineMode(context, true),
|
||||
}),
|
||||
};
|
||||
|
||||
const disableCommand: SlashCommand = {
|
||||
name: 'off',
|
||||
altNames: ['disable'],
|
||||
description: 'Disable offline mode',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: async (context) => ({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: await setOfflineMode(context, false),
|
||||
}),
|
||||
};
|
||||
|
||||
export const offlineCommand: SlashCommand = {
|
||||
name: 'offline',
|
||||
description: 'Manage offline mode and cloud delegation behavior',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
isSafeConcurrent: true,
|
||||
subCommands: [statusCommand, enableCommand, disableCommand],
|
||||
action: async (context) => ({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: getStatusMessage(context),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { usePulsingColor } from '../hooks/usePulsingColor.js';
|
||||
|
||||
interface PulsingDotProps {
|
||||
/** Full-brightness color */
|
||||
color: string;
|
||||
/** Dim color at the trough of the pulse */
|
||||
dimColor: string;
|
||||
/** Duration of one full pulse cycle in ms */
|
||||
cycleDurationMs: number;
|
||||
/** Whether the dot is actively pulsing. When false, renders static at full color. */
|
||||
active: boolean;
|
||||
/** Optional label text rendered after the dot */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const PulsingDot: React.FC<PulsingDotProps> = ({
|
||||
color,
|
||||
dimColor,
|
||||
cycleDurationMs,
|
||||
active,
|
||||
label,
|
||||
}) => {
|
||||
const currentColor = usePulsingColor(
|
||||
color,
|
||||
dimColor,
|
||||
cycleDurationMs,
|
||||
active,
|
||||
);
|
||||
|
||||
return (
|
||||
<Text color={currentColor}>
|
||||
{active ? '◉' : '●'} {label}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -34,6 +34,7 @@ describe('<StatusRow />', () => {
|
||||
contextFileNames: [],
|
||||
showApprovalModeIndicator: ApprovalMode.DEFAULT,
|
||||
allowPlanMode: false,
|
||||
isOfflineMode: false,
|
||||
renderMarkdown: true,
|
||||
currentModel: 'gemini-3',
|
||||
};
|
||||
@@ -46,6 +47,8 @@ describe('<StatusRow />', () => {
|
||||
showWit: true,
|
||||
modeContentObj: null,
|
||||
showMinimalContext: false,
|
||||
isOfflineMode: false,
|
||||
cloudSubagentActive: false,
|
||||
});
|
||||
|
||||
const uiState: Partial<UIState> = {
|
||||
@@ -86,6 +89,8 @@ describe('<StatusRow />', () => {
|
||||
showWit: false,
|
||||
modeContentObj: null,
|
||||
showMinimalContext: false,
|
||||
isOfflineMode: false,
|
||||
cloudSubagentActive: false,
|
||||
});
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
@@ -115,6 +120,8 @@ describe('<StatusRow />', () => {
|
||||
showWit: true,
|
||||
modeContentObj: null,
|
||||
showMinimalContext: false,
|
||||
isOfflineMode: false,
|
||||
cloudSubagentActive: false,
|
||||
});
|
||||
|
||||
const uiState: Partial<UIState> = {
|
||||
@@ -140,4 +147,79 @@ describe('<StatusRow />', () => {
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Tip: Test Tip');
|
||||
});
|
||||
|
||||
it('renders offline mode indicator in detailed UI', async () => {
|
||||
(useComposerStatus as Mock).mockReturnValue({
|
||||
isInteractiveShellWaiting: false,
|
||||
showLoadingIndicator: false,
|
||||
showTips: false,
|
||||
showWit: false,
|
||||
modeContentObj: null,
|
||||
showMinimalContext: false,
|
||||
isOfflineMode: true,
|
||||
cloudSubagentActive: false,
|
||||
});
|
||||
|
||||
const uiState: Partial<UIState> = {
|
||||
...defaultUiState,
|
||||
isOfflineMode: true,
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<StatusRow
|
||||
showUiDetails={true}
|
||||
isNarrow={false}
|
||||
terminalWidth={100}
|
||||
hideContextSummary={false}
|
||||
hideUiDetailsForSuggestions={false}
|
||||
hasPendingActionRequired={false}
|
||||
/>,
|
||||
{
|
||||
width: 100,
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('offline');
|
||||
});
|
||||
|
||||
it('renders cloud indicator when cloud subagent is active', async () => {
|
||||
(useComposerStatus as Mock).mockReturnValue({
|
||||
isInteractiveShellWaiting: false,
|
||||
showLoadingIndicator: false,
|
||||
showTips: false,
|
||||
showWit: false,
|
||||
modeContentObj: null,
|
||||
showMinimalContext: false,
|
||||
isOfflineMode: true,
|
||||
cloudSubagentActive: true,
|
||||
});
|
||||
|
||||
const uiState: Partial<UIState> = {
|
||||
...defaultUiState,
|
||||
isOfflineMode: true,
|
||||
cloudSubagentActive: true,
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<StatusRow
|
||||
showUiDetails={true}
|
||||
isNarrow={false}
|
||||
terminalWidth={100}
|
||||
hideContextSummary={false}
|
||||
hideUiDetailsForSuggestions={false}
|
||||
hasPendingActionRequired={false}
|
||||
/>,
|
||||
{
|
||||
width: 100,
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('offline');
|
||||
expect(output).toContain('cloud');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type ThoughtSummary,
|
||||
} from '@google/gemini-cli-core';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { type ActiveHook } from '../types.js';
|
||||
import { type ActiveHook, StreamingState } from '../types.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
@@ -25,7 +25,9 @@ import { HorizontalLine } from './shared/HorizontalLine.js';
|
||||
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
|
||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
|
||||
import { PulsingDot } from './PulsingDot.js';
|
||||
import { useComposerStatus } from '../hooks/useComposerStatus.js';
|
||||
import { useStreamingContext } from '../contexts/StreamingContext.js';
|
||||
|
||||
/**
|
||||
* Layout constants to prevent magic numbers.
|
||||
@@ -173,7 +175,14 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
showWit,
|
||||
modeContentObj,
|
||||
showMinimalContext,
|
||||
isOfflineMode,
|
||||
cloudSubagentActive,
|
||||
} = useComposerStatus();
|
||||
const streamingState = useStreamingContext();
|
||||
const isLocalActive =
|
||||
isOfflineMode &&
|
||||
streamingState === StreamingState.Responding &&
|
||||
!cloudSubagentActive;
|
||||
|
||||
const [statusWidth, setStatusWidth] = useState(0);
|
||||
const [tipWidth, setTipWidth] = useState(0);
|
||||
@@ -411,6 +420,30 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
<RawMarkdownIndicator />
|
||||
</Box>
|
||||
)}
|
||||
{isOfflineMode && (
|
||||
<Box
|
||||
marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
>
|
||||
<PulsingDot
|
||||
color={theme.status.success}
|
||||
dimColor={theme.ui.dark}
|
||||
cycleDurationMs={1500}
|
||||
active={isLocalActive}
|
||||
label="offline"
|
||||
/>
|
||||
{cloudSubagentActive && (
|
||||
<PulsingDot
|
||||
color={theme.status.warning}
|
||||
dimColor={theme.ui.dark}
|
||||
cycleDurationMs={800}
|
||||
active={true}
|
||||
label="cloud"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
showRow2Minimal &&
|
||||
|
||||
@@ -62,18 +62,25 @@ export const SubagentProgressDisplay: React.FC<
|
||||
let headerText: string | undefined;
|
||||
let headerColor = theme.text.secondary;
|
||||
|
||||
const isCloud =
|
||||
progress.agentName === 'cloud-subagent' ||
|
||||
progress.agentName === 'cloud_subagent';
|
||||
const prefix = isCloud ? '☁ Cloud' : `Subagent ${progress.agentName}`;
|
||||
|
||||
if (progress.state === 'cancelled') {
|
||||
headerText = `Subagent ${progress.agentName} was cancelled.`;
|
||||
headerText = `${prefix} was cancelled.`;
|
||||
headerColor = theme.status.warning;
|
||||
} else if (progress.state === 'error') {
|
||||
headerText = `Subagent ${progress.agentName} failed.`;
|
||||
headerText = `${prefix} failed.`;
|
||||
headerColor = theme.status.error;
|
||||
} else if (progress.state === 'completed') {
|
||||
headerText = `Subagent ${progress.agentName} completed.`;
|
||||
headerText = `${prefix} completed.`;
|
||||
headerColor = theme.status.success;
|
||||
} else {
|
||||
headerText = `Running subagent ${progress.agentName}...`;
|
||||
headerColor = theme.text.primary;
|
||||
headerText = isCloud
|
||||
? `☁ Running cloud subagent...`
|
||||
: `Running subagent ${progress.agentName}...`;
|
||||
headerColor = isCloud ? theme.status.warning : theme.text.primary;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -97,6 +97,33 @@ describe('ToolConfirmationMessage', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should use allow/always allow/deny labels for cloud-subagent confirmations', async () => {
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'info',
|
||||
title: '☁ Delegate to cloud subagent',
|
||||
prompt:
|
||||
'This will run with full tool access in cloud mode.\n\nAnalyze migration risks across the codebase.',
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
getPreferredEditor={vi.fn()}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
toolName="cloud-subagent"
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('1. Allow');
|
||||
expect(output).toContain('2. Always allow');
|
||||
expect(output).toContain('3. Deny (esc)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display WarningMessage for deceptive URLs in info type', async () => {
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'info',
|
||||
|
||||
@@ -371,29 +371,46 @@ export const ToolConfirmationMessage: React.FC<
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
} else if (confirmationDetails.type === 'info') {
|
||||
const isCloudSubagentConfirmation =
|
||||
toolName === 'cloud-subagent' ||
|
||||
toolName === 'cloud_subagent' ||
|
||||
confirmationDetails.title?.includes('cloud subagent');
|
||||
|
||||
options.push({
|
||||
label: 'Allow once',
|
||||
label: isCloudSubagentConfirmation ? 'Allow' : 'Allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Allow once',
|
||||
key: isCloudSubagentConfirmation ? 'Allow' : 'Allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: 'Allow for this session',
|
||||
label: isCloudSubagentConfirmation
|
||||
? 'Always allow'
|
||||
: 'Allow for this session',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Allow for this session',
|
||||
key: isCloudSubagentConfirmation
|
||||
? 'Always allow'
|
||||
: 'Allow for this session',
|
||||
});
|
||||
if (allowPermanentApproval) {
|
||||
options.push({
|
||||
label: 'Allow for all future sessions',
|
||||
label: isCloudSubagentConfirmation
|
||||
? 'Always allow for all future sessions'
|
||||
: 'Allow for all future sessions',
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
key: 'Allow for all future sessions',
|
||||
key: isCloudSubagentConfirmation
|
||||
? 'Always allow for all future sessions'
|
||||
: 'Allow for all future sessions',
|
||||
});
|
||||
}
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
label: isCloudSubagentConfirmation
|
||||
? 'Deny (esc)'
|
||||
: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
key: isCloudSubagentConfirmation
|
||||
? 'Deny (esc)'
|
||||
: 'No, suggest changes (esc)',
|
||||
});
|
||||
} else if (confirmationDetails.type === 'mcp') {
|
||||
options.push({
|
||||
@@ -433,6 +450,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
allowPermanentApproval,
|
||||
config,
|
||||
isDiffingEnabled,
|
||||
toolName,
|
||||
]);
|
||||
|
||||
const availableBodyContentHeight = useCallback(() => {
|
||||
|
||||
+13
-113
@@ -8,7 +8,7 @@ exports[`ToolConfirmationMessage > enablePermanentToolApproval setting > should
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Apply this change?
|
||||
|
||||
● 1. Allow once
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
3. Allow for this file in all future sessions ~/.gemini/policies/auto-saved.toml
|
||||
4. Modify with external editor
|
||||
@@ -16,92 +16,6 @@ Apply this change?
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolConfirmationMessage > height allocation and layout > should expand to available height for large edit diffs 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ... 10 hidden (Ctrl+O) ... │
|
||||
│ 6 - const oldLine6 = true; │
|
||||
│ 6 + const newLine6 = true; │
|
||||
│ 7 - const oldLine7 = true; │
|
||||
│ 7 + const newLine7 = true; │
|
||||
│ 8 - const oldLine8 = true; │
|
||||
│ 8 + const newLine8 = true; │
|
||||
│ 9 - const oldLine9 = true; │
|
||||
│ 9 + const newLine9 = true; │
|
||||
│ 10 - const oldLine10 = true; │
|
||||
│ 10 + const newLine10 = true; │
|
||||
│ 11 - const oldLine11 = true; │
|
||||
│ 11 + const newLine11 = true; │
|
||||
│ 12 - const oldLine12 = true; │
|
||||
│ 12 + const newLine12 = true; │
|
||||
│ 13 - const oldLine13 = true; │
|
||||
│ 13 + const newLine13 = true; │
|
||||
│ 14 - const oldLine14 = true; │
|
||||
│ 14 + const newLine14 = true; │
|
||||
│ 15 - const oldLine15 = true; │
|
||||
│ 15 + const newLine15 = true; │
|
||||
│ 16 - const oldLine16 = true; │
|
||||
│ 16 + const newLine16 = true; │
|
||||
│ 17 - const oldLine17 = true; │
|
||||
│ 17 + const newLine17 = true; │
|
||||
│ 18 - const oldLine18 = true; │
|
||||
│ 18 + const newLine18 = true; │
|
||||
│ 19 - const oldLine19 = true; │
|
||||
│ 19 + const newLine19 = true; │
|
||||
│ 20 - const oldLine20 = true; │
|
||||
│ 20 + const newLine20 = true; │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Apply this change?
|
||||
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
3. Modify with external editor
|
||||
4. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolConfirmationMessage > height allocation and layout > should expand to available height for large exec commands 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ... 19 hidden (Ctrl+O) ... │
|
||||
│ echo "Line 20" │
|
||||
│ echo "Line 21" │
|
||||
│ echo "Line 22" │
|
||||
│ echo "Line 23" │
|
||||
│ echo "Line 24" │
|
||||
│ echo "Line 25" │
|
||||
│ echo "Line 26" │
|
||||
│ echo "Line 27" │
|
||||
│ echo "Line 28" │
|
||||
│ echo "Line 29" │
|
||||
│ echo "Line 30" │
|
||||
│ echo "Line 31" │
|
||||
│ echo "Line 32" │
|
||||
│ echo "Line 33" │
|
||||
│ echo "Line 34" │
|
||||
│ echo "Line 35" │
|
||||
│ echo "Line 36" │
|
||||
│ echo "Line 37" │
|
||||
│ echo "Line 38" │
|
||||
│ echo "Line 39" │
|
||||
│ echo "Line 40" │
|
||||
│ echo "Line 41" │
|
||||
│ echo "Line 42" │
|
||||
│ echo "Line 43" │
|
||||
│ echo "Line 44" │
|
||||
│ echo "Line 45" │
|
||||
│ echo "Line 46" │
|
||||
│ echo "Line 47" │
|
||||
│ echo "Line 48" │
|
||||
│ echo "Line 49" │
|
||||
│ echo "Line 50" │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Allow execution of [echo]?
|
||||
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolConfirmationMessage > should display multiple commands for exec type when provided 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ echo "hello" │
|
||||
@@ -112,7 +26,7 @@ exports[`ToolConfirmationMessage > should display multiple commands for exec typ
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Allow execution of [echo, ls, whoami]?
|
||||
|
||||
● 1. Allow once
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
@@ -125,7 +39,7 @@ URLs to fetch:
|
||||
- https://raw.githubusercontent.com/google/gemini-react/main/README.md
|
||||
Do you want to proceed?
|
||||
|
||||
● 1. Allow once
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
@@ -135,32 +49,18 @@ exports[`ToolConfirmationMessage > should not display urls if prompt and url are
|
||||
"https://example.com
|
||||
Do you want to proceed?
|
||||
|
||||
● 1. Allow once
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolConfirmationMessage > should render multiline shell scripts with correct newlines and syntax highlighting 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ echo "hello" │
|
||||
│ for i in 1 2 3; do │
|
||||
│ echo $i │
|
||||
│ done │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Allow execution of [echo]?
|
||||
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
3. No, suggest changes (esc)"
|
||||
`;
|
||||
|
||||
exports[`ToolConfirmationMessage > should strip BiDi characters from MCP tool and server names 1`] = `
|
||||
"MCP Server: testserver
|
||||
Tool: testtool
|
||||
Allow execution of MCP tool "testtool" from server "testserver"?
|
||||
|
||||
● 1. Allow once
|
||||
● 1. Allow once
|
||||
2. Allow tool for this session
|
||||
3. Allow all server tools for this session
|
||||
4. No, suggest changes (esc)
|
||||
@@ -175,7 +75,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Apply this change?
|
||||
|
||||
● 1. Allow once
|
||||
● 1. Allow once
|
||||
2. Modify with external editor
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
@@ -189,7 +89,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Apply this change?
|
||||
|
||||
● 1. Allow once
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
3. Modify with external editor
|
||||
4. No, suggest changes (esc)
|
||||
@@ -202,7 +102,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Allow execution of [echo]?
|
||||
|
||||
● 1. Allow once
|
||||
● 1. Allow once
|
||||
2. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
@@ -213,7 +113,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Allow execution of [echo]?
|
||||
|
||||
● 1. Allow once
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
@@ -223,7 +123,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations'
|
||||
"https://example.com
|
||||
Do you want to proceed?
|
||||
|
||||
● 1. Allow once
|
||||
● 1. Allow once
|
||||
2. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
@@ -232,7 +132,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations'
|
||||
"https://example.com
|
||||
Do you want to proceed?
|
||||
|
||||
● 1. Allow once
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
@@ -243,7 +143,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' >
|
||||
Tool: test-tool
|
||||
Allow execution of MCP tool "test-tool" from server "test-server"?
|
||||
|
||||
● 1. Allow once
|
||||
● 1. Allow once
|
||||
2. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
@@ -253,7 +153,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' >
|
||||
Tool: test-tool
|
||||
Allow execution of MCP tool "test-tool" from server "test-server"?
|
||||
|
||||
● 1. Allow once
|
||||
● 1. Allow once
|
||||
2. Allow tool for this session
|
||||
3. Allow all server tools for this session
|
||||
4. No, suggest changes (esc)
|
||||
|
||||
@@ -157,6 +157,8 @@ export interface UIState {
|
||||
queueErrorMessage: string | null;
|
||||
showApprovalModeIndicator: ApprovalMode;
|
||||
allowPlanMode: boolean;
|
||||
isOfflineMode?: boolean;
|
||||
cloudSubagentActive?: boolean;
|
||||
currentModel: string;
|
||||
contextFileNames: string[];
|
||||
errorCount: number;
|
||||
|
||||
@@ -21,6 +21,8 @@ export const useComposerStatus = () => {
|
||||
const uiState = useUIState();
|
||||
const quotaState = useQuotaState();
|
||||
const settings = useSettings();
|
||||
const isOfflineMode = Boolean(uiState.isOfflineMode);
|
||||
const cloudSubagentActive = Boolean(uiState.cloudSubagentActive);
|
||||
|
||||
const hasPendingToolConfirmation = useMemo(
|
||||
() =>
|
||||
@@ -64,22 +66,50 @@ export const useComposerStatus = () => {
|
||||
|
||||
if (hideMinimalModeHintWhileBusy) return null;
|
||||
|
||||
switch (showApprovalModeIndicator) {
|
||||
case ApprovalMode.YOLO:
|
||||
return { text: 'YOLO', color: theme.status.error };
|
||||
case ApprovalMode.PLAN:
|
||||
return { text: 'plan', color: theme.status.success };
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
return { text: 'auto edit', color: theme.status.warning };
|
||||
case ApprovalMode.DEFAULT:
|
||||
default:
|
||||
return null;
|
||||
const approvalModeIndicator = (() => {
|
||||
switch (showApprovalModeIndicator) {
|
||||
case ApprovalMode.YOLO:
|
||||
return { text: 'YOLO', color: theme.status.error };
|
||||
case ApprovalMode.PLAN:
|
||||
return { text: 'plan', color: theme.status.success };
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
return { text: 'auto edit', color: theme.status.warning };
|
||||
case ApprovalMode.DEFAULT:
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
if (approvalModeIndicator) {
|
||||
const suffix = cloudSubagentActive
|
||||
? ' + cloud'
|
||||
: isOfflineMode
|
||||
? ' + offline'
|
||||
: '';
|
||||
return suffix
|
||||
? {
|
||||
text: `${approvalModeIndicator.text}${suffix}`,
|
||||
color: approvalModeIndicator.color,
|
||||
}
|
||||
: approvalModeIndicator;
|
||||
}
|
||||
|
||||
if (cloudSubagentActive) {
|
||||
return { text: 'cloud', color: theme.status.warning };
|
||||
}
|
||||
|
||||
if (isOfflineMode) {
|
||||
return { text: 'offline', color: theme.status.success };
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
uiState.cleanUiDetailsVisible,
|
||||
showLoadingIndicator,
|
||||
uiState.activeHooks.length,
|
||||
showApprovalModeIndicator,
|
||||
isOfflineMode,
|
||||
cloudSubagentActive,
|
||||
]);
|
||||
|
||||
const showMinimalContext = isContextUsageHigh(
|
||||
@@ -108,5 +138,7 @@ export const useComposerStatus = () => {
|
||||
showWit,
|
||||
modeContentObj,
|
||||
showMinimalContext,
|
||||
isOfflineMode,
|
||||
cloudSubagentActive,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { interpolateColor } from '../themes/color-utils.js';
|
||||
|
||||
const FRAME_INTERVAL_MS = 60; // ~16fps — smooth enough for a pulse, cheap on CPU
|
||||
|
||||
/**
|
||||
* Returns a color that pulses between `activeColor` and `dimColor` on a sine
|
||||
* curve. When `active` is false the hook stops its timer and returns
|
||||
* `activeColor` at full brightness (static dot).
|
||||
*/
|
||||
export function usePulsingColor(
|
||||
activeColor: string,
|
||||
dimColor: string,
|
||||
cycleDurationMs: number,
|
||||
active: boolean,
|
||||
): string {
|
||||
const [time, setTime] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setTime(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setTime((prev) => prev + FRAME_INTERVAL_MS);
|
||||
}, FRAME_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [active]);
|
||||
|
||||
if (!active) {
|
||||
return activeColor;
|
||||
}
|
||||
|
||||
// Sine oscillation: 0 → 1 → 0 over one cycle
|
||||
const progress = (Math.sin((2 * Math.PI * time) / cycleDurationMs) + 1) / 2;
|
||||
return interpolateColor(dimColor, activeColor, progress) || activeColor;
|
||||
}
|
||||
@@ -7,13 +7,17 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AgentTool } from './agent-tool.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import {
|
||||
createMockMessageBus,
|
||||
getMockMessageBusInstance,
|
||||
} from '../test-utils/mock-message-bus.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { LocalSubagentInvocation } from './local-invocation.js';
|
||||
import { RemoteAgentInvocation } from './remote-invocation.js';
|
||||
import { BrowserAgentInvocation } from './browser/browserAgentInvocation.js';
|
||||
import { BROWSER_AGENT_NAME } from './browser/browserAgentDefinition.js';
|
||||
import { CLOUD_SUBAGENT_NAME } from './cloud-subagent.js';
|
||||
import { AgentRegistry } from './registry.js';
|
||||
import type { LocalAgentDefinition, RemoteAgentDefinition } from './types.js';
|
||||
|
||||
@@ -54,6 +58,25 @@ describe('AgentTool', () => {
|
||||
agentCardUrl: 'http://example.com/agent',
|
||||
};
|
||||
|
||||
const cloudSubagentDefinition: LocalAgentDefinition = {
|
||||
kind: 'local',
|
||||
name: CLOUD_SUBAGENT_NAME,
|
||||
displayName: 'cloud-subagent',
|
||||
description: 'Cloud delegation specialist.',
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
request: { type: 'string' },
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
},
|
||||
modelConfig: { model: 'test', generateContentConfig: {} },
|
||||
runConfig: { maxTimeMinutes: 1 },
|
||||
promptConfig: { systemPrompt: 'test' },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = makeFakeConfig();
|
||||
@@ -67,6 +90,7 @@ describe('AgentTool', () => {
|
||||
vi.spyOn(registry, 'getDefinition').mockImplementation((name: string) => {
|
||||
if (name === 'TestLocalAgent') return testLocalDefinition;
|
||||
if (name === 'TestRemoteAgent') return testRemoteDefinition;
|
||||
if (name === CLOUD_SUBAGENT_NAME) return cloudSubagentDefinition;
|
||||
if (name === BROWSER_AGENT_NAME) {
|
||||
return {
|
||||
kind: 'remote',
|
||||
@@ -141,4 +165,37 @@ describe('AgentTool', () => {
|
||||
'Invoke Browser Agent',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use concise cloud-subagent description text', () => {
|
||||
const params = {
|
||||
agent_name: CLOUD_SUBAGENT_NAME,
|
||||
prompt: 'Analyze all package-level config and summarize migration risks.',
|
||||
};
|
||||
const invocation = tool['createInvocation'](params, mockMessageBus);
|
||||
const description = invocation.getDescription();
|
||||
|
||||
expect(description).toBe(
|
||||
'Delegating to cloud-subagent for complex cloud execution',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return custom confirmation details for cloud-subagent', async () => {
|
||||
getMockMessageBusInstance(mockMessageBus).defaultToolDecision = 'ask_user';
|
||||
const params = {
|
||||
agent_name: CLOUD_SUBAGENT_NAME,
|
||||
prompt: 'Summarize risk hotspots and propose migration sequencing.',
|
||||
};
|
||||
const invocation = tool['createInvocation'](params, mockMessageBus);
|
||||
|
||||
const result = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'info',
|
||||
title: '☁ Delegate to cloud subagent',
|
||||
});
|
||||
// Should NOT delegate to child invocation for confirmation
|
||||
expect(LocalSubagentInvocation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,21 @@ import {
|
||||
GEN_AI_AGENT_NAME,
|
||||
} from '../telemetry/constants.js';
|
||||
import { AGENT_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import { CLOUD_SUBAGENT_NAME } from './cloud-subagent.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
|
||||
const CLOUD_DELEGATION_PROMPT_MAX_LENGTH = 280;
|
||||
|
||||
function truncateText(value: unknown, maxLength: number): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return 'Cloud delegation requested.';
|
||||
}
|
||||
const normalized = value.replace(/\s+/g, ' ').trim();
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A unified tool for invoking subagents.
|
||||
@@ -144,6 +159,9 @@ class DelegateInvocation extends BaseToolInvocation<
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
if (this.definition.name === CLOUD_SUBAGENT_NAME) {
|
||||
return 'Delegating to cloud-subagent for complex cloud execution';
|
||||
}
|
||||
return `Delegating to agent '${this.definition.name}'`;
|
||||
}
|
||||
|
||||
@@ -180,15 +198,45 @@ class DelegateInvocation extends BaseToolInvocation<
|
||||
override async shouldConfirmExecute(
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<ToolCallConfirmationDetails | false> {
|
||||
if (this.definition.name === CLOUD_SUBAGENT_NAME) {
|
||||
return super.shouldConfirmExecute(abortSignal);
|
||||
}
|
||||
const hintedParams = this.withUserHints(this.mappedInputs);
|
||||
const invocation = this.buildChildInvocation(hintedParams);
|
||||
return invocation.shouldConfirmExecute(abortSignal);
|
||||
}
|
||||
|
||||
protected override async getConfirmationDetails(
|
||||
_abortSignal: AbortSignal,
|
||||
): Promise<ToolCallConfirmationDetails | false> {
|
||||
if (this.definition.name !== CLOUD_SUBAGENT_NAME) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prompt = truncateText(
|
||||
this.mappedInputs['request'] ?? this.params.prompt,
|
||||
CLOUD_DELEGATION_PROMPT_MAX_LENGTH,
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'info',
|
||||
title: '☁ Delegate to cloud subagent',
|
||||
prompt: `This will run with full tool access in cloud mode.\n\n${prompt}`,
|
||||
onConfirm: async (_outcome) => {
|
||||
// Policy updates are handled centrally by the scheduler.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async execute(options: ExecuteOptions): Promise<ToolResult> {
|
||||
const { abortSignal: signal, updateOutput } = options;
|
||||
const hintedParams = this.withUserHints(this.mappedInputs);
|
||||
const invocation = this.buildChildInvocation(hintedParams);
|
||||
const isCloud = this.definition.name === CLOUD_SUBAGENT_NAME;
|
||||
|
||||
if (isCloud) {
|
||||
coreEvents.emitCloudSubagentExecution(this.definition.name, 'started');
|
||||
}
|
||||
|
||||
return runInDevTraceSpan(
|
||||
{
|
||||
@@ -202,12 +250,30 @@ class DelegateInvocation extends BaseToolInvocation<
|
||||
},
|
||||
async ({ metadata }) => {
|
||||
metadata.input = this.params;
|
||||
const result = await invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
metadata.output = result;
|
||||
return result;
|
||||
try {
|
||||
const result = await invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
metadata.output = result;
|
||||
if (isCloud) {
|
||||
coreEvents.emitCloudSubagentExecution(
|
||||
this.definition.name,
|
||||
'ended',
|
||||
'success',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (isCloud) {
|
||||
coreEvents.emitCloudSubagentExecution(
|
||||
this.definition.name,
|
||||
'ended',
|
||||
signal.aborted ? 'cancelled' : 'error',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CloudSubagent, CLOUD_SUBAGENT_NAME } from './cloud-subagent.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { getCoreSystemPrompt } from '../core/prompts.js';
|
||||
|
||||
vi.mock('../core/prompts.js', () => ({
|
||||
getCoreSystemPrompt: vi.fn().mockReturnValue('BASE PROMPT'),
|
||||
}));
|
||||
|
||||
describe('CloudSubagent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should lazily build promptConfig without eager system prompt rendering', () => {
|
||||
const config = { sessionId: 'test' };
|
||||
const context = {
|
||||
config,
|
||||
toolRegistry: undefined,
|
||||
} as unknown as AgentLoopContext;
|
||||
|
||||
const agent = CloudSubagent(context);
|
||||
|
||||
expect(getCoreSystemPrompt).not.toHaveBeenCalled();
|
||||
|
||||
const promptConfig = agent.promptConfig;
|
||||
expect(getCoreSystemPrompt).toHaveBeenCalledWith(config, undefined, false);
|
||||
expect(promptConfig.systemPrompt).toContain('Cloud Delegation Protocol');
|
||||
});
|
||||
|
||||
it('should exclude itself from the available tool list', () => {
|
||||
const config = { sessionId: 'test' };
|
||||
const context = {
|
||||
config,
|
||||
toolRegistry: {
|
||||
getAllToolNames: vi
|
||||
.fn()
|
||||
.mockReturnValue(['read_file', CLOUD_SUBAGENT_NAME, 'shell']),
|
||||
},
|
||||
} as unknown as AgentLoopContext;
|
||||
|
||||
const agent = CloudSubagent(context);
|
||||
|
||||
expect(agent.toolConfig?.tools).toEqual(['read_file', 'shell']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { getCoreSystemPrompt } from '../core/prompts.js';
|
||||
import type { LocalAgentDefinition } from './types.js';
|
||||
|
||||
export const CLOUD_SUBAGENT_NAME = 'cloud_subagent';
|
||||
|
||||
const CloudSubagentOutputSchema = z.object({
|
||||
summary: z
|
||||
.string()
|
||||
.describe(
|
||||
'A polished summary of findings, decisions, and outcomes from the delegated cloud task.',
|
||||
),
|
||||
});
|
||||
|
||||
export const CloudSubagent = (
|
||||
context: AgentLoopContext,
|
||||
): LocalAgentDefinition<typeof CloudSubagentOutputSchema> => ({
|
||||
kind: 'local',
|
||||
name: CLOUD_SUBAGENT_NAME,
|
||||
displayName: 'cloud-subagent',
|
||||
description:
|
||||
'Delegation specialist for complex or high-context tasks while offline mode is enabled. Use when work is likely to be long-running, high-volume, or exploratory, then return a crisp and elegant summary.',
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
request: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The delegated task to execute in the cloud context. Include both what to do and why cloud delegation is justified.',
|
||||
},
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
},
|
||||
outputConfig: {
|
||||
outputName: 'result',
|
||||
description: 'A concise but eloquent summary of the delegated task result.',
|
||||
schema: CloudSubagentOutputSchema,
|
||||
},
|
||||
processOutput: (output) => output.summary,
|
||||
modelConfig: {
|
||||
model: 'inherit',
|
||||
},
|
||||
get toolConfig() {
|
||||
const tools = (context.toolRegistry?.getAllToolNames() ?? []).filter(
|
||||
(toolName) => toolName !== CLOUD_SUBAGENT_NAME,
|
||||
);
|
||||
return {
|
||||
tools,
|
||||
};
|
||||
},
|
||||
get promptConfig() {
|
||||
return {
|
||||
query: '${request}',
|
||||
systemPrompt: `${getCoreSystemPrompt(
|
||||
context.config,
|
||||
/* useMemory */ undefined,
|
||||
/* interactiveOverride */ false,
|
||||
)}
|
||||
|
||||
# Cloud Delegation Protocol
|
||||
|
||||
- You are the dedicated cloud execution specialist.
|
||||
- Prioritize complex, high-volume, or exploratory work delegated by the main offline-mode agent.
|
||||
- Execute thoroughly, but keep the final answer compact and structured.
|
||||
- Your final summary must be elegant and useful:
|
||||
- Outcome first.
|
||||
- Key findings and decisions second.
|
||||
- Important caveats or follow-ups last.
|
||||
- Avoid unnecessary verbosity and avoid exposing internal deliberation.
|
||||
|
||||
You MUST call \`complete_task\` with a JSON object containing the \`summary\`.`,
|
||||
};
|
||||
},
|
||||
runConfig: {
|
||||
maxTimeMinutes: 15,
|
||||
maxTurns: 25,
|
||||
},
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { loadAgentsFromDirectory } from './agentLoader.js';
|
||||
import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
|
||||
import { CliHelpAgent } from './cli-help-agent.js';
|
||||
import { GeneralistAgent } from './generalist-agent.js';
|
||||
import { CloudSubagent, CLOUD_SUBAGENT_NAME } from './cloud-subagent.js';
|
||||
import { BrowserAgentDefinition } from './browser/browserAgentDefinition.js';
|
||||
import { MemoryManagerAgent } from './memory-manager-agent.js';
|
||||
import { AgentTool } from './agent-tool.js';
|
||||
@@ -266,6 +267,9 @@ export class AgentRegistry {
|
||||
this.registerLocalAgent(CodebaseInvestigatorAgent(this.config));
|
||||
this.registerLocalAgent(CliHelpAgent(this.config));
|
||||
this.registerLocalAgent(GeneralistAgent(this.config));
|
||||
if (this.config.isOfflineModeEnabled()) {
|
||||
this.registerLocalAgent(CloudSubagent(this.config));
|
||||
}
|
||||
|
||||
// Register the browser agent if enabled in settings.
|
||||
// Tools are configured dynamically at invocation time via browserAgentFactory.
|
||||
@@ -391,8 +395,10 @@ export class AgentRegistry {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only add override for remote agents. Local agents are handled by blanket allow.
|
||||
if (definition.kind === 'remote') {
|
||||
// Only add override for remote agents and cloud subagent.
|
||||
// Local agents are handled by blanket allow, but cloud subagent needs
|
||||
// explicit ASK_USER since it delegates work to a cloud model.
|
||||
if (definition.kind === 'remote' || definition.name === CLOUD_SUBAGENT_NAME) {
|
||||
policyEngine.addRule({
|
||||
toolName: AgentTool.Name,
|
||||
argsPattern: new RegExp(`"agent_name":\\s*"${definition.name}"`),
|
||||
|
||||
@@ -199,6 +199,7 @@ vi.mock('../resources/resource-registry.js', () => ({
|
||||
const mockCoreEvents = vi.hoisted(() => ({
|
||||
emitFeedback: vi.fn(),
|
||||
emitModelChanged: vi.fn(),
|
||||
emitOfflineModeChanged: vi.fn(),
|
||||
emitConsoleLog: vi.fn(),
|
||||
emitQuotaChanged: vi.fn(),
|
||||
on: vi.fn(),
|
||||
@@ -1849,6 +1850,48 @@ describe('GemmaModelRouterSettings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('OfflineSettings', () => {
|
||||
const baseParams: ConfigParameters = {
|
||||
sessionId: 'test-offline',
|
||||
targetDir: '.',
|
||||
debugMode: false,
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
cwd: '.',
|
||||
};
|
||||
|
||||
it('should default offline mode to disabled when not provided', () => {
|
||||
const config = new Config(baseParams);
|
||||
expect(config.isOfflineModeEnabled()).toBe(false);
|
||||
expect(config.getOfflineSettings().localModelRouting).toBe(
|
||||
'stub_default_api',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use provided offline settings', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
offline: {
|
||||
enabled: true,
|
||||
localModelRouting: 'stub_default_api',
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.isOfflineModeEnabled()).toBe(true);
|
||||
expect(config.getOfflineSettings()).toEqual({
|
||||
enabled: true,
|
||||
localModelRouting: 'stub_default_api',
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit offline mode change events when toggled', async () => {
|
||||
const config = new Config(baseParams);
|
||||
|
||||
await config.setOfflineMode(true);
|
||||
expect(mockCoreEvents.emitOfflineModeChanged).toHaveBeenCalledWith(true);
|
||||
expect(config.isOfflineModeEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setApprovalMode with folder trust', () => {
|
||||
const baseParams: ConfigParameters = {
|
||||
sessionId: 'test',
|
||||
|
||||
@@ -200,6 +200,13 @@ export interface PlanSettings {
|
||||
modelRouting?: boolean;
|
||||
}
|
||||
|
||||
export type OfflineLocalModelRouting = 'stub_default_api';
|
||||
|
||||
export interface OfflineSettings {
|
||||
enabled?: boolean;
|
||||
localModelRouting?: OfflineLocalModelRouting;
|
||||
}
|
||||
|
||||
export interface TelemetrySettings {
|
||||
enabled?: boolean;
|
||||
target?: TelemetryTarget;
|
||||
@@ -710,6 +717,7 @@ export interface ConfigParameters {
|
||||
disableLLMCorrection?: boolean;
|
||||
plan?: boolean;
|
||||
tracker?: boolean;
|
||||
offline?: OfflineSettings;
|
||||
planSettings?: PlanSettings;
|
||||
worktreeSettings?: WorktreeSettings;
|
||||
modelSteering?: boolean;
|
||||
@@ -946,6 +954,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly disableLLMCorrection: boolean;
|
||||
private readonly planEnabled: boolean;
|
||||
private readonly trackerEnabled: boolean;
|
||||
private offlineSettings: {
|
||||
enabled: boolean;
|
||||
localModelRouting: OfflineLocalModelRouting;
|
||||
};
|
||||
private readonly planModeRoutingEnabled: boolean;
|
||||
private readonly modelSteering: boolean;
|
||||
private memoryContextManager?: MemoryContextManager;
|
||||
@@ -1095,6 +1107,11 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
|
||||
this.planEnabled = params.plan ?? true;
|
||||
this.trackerEnabled = params.tracker ?? false;
|
||||
this.offlineSettings = {
|
||||
enabled: params.offline?.enabled ?? false,
|
||||
localModelRouting:
|
||||
params.offline?.localModelRouting ?? 'stub_default_api',
|
||||
};
|
||||
this.planModeRoutingEnabled = params.planSettings?.modelRouting ?? true;
|
||||
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
|
||||
this.skillsSupport = params.skillsSupport ?? true;
|
||||
@@ -2886,6 +2903,17 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.directWebFetch;
|
||||
}
|
||||
|
||||
isOfflineModeEnabled(): boolean {
|
||||
return this.offlineSettings.enabled;
|
||||
}
|
||||
|
||||
getOfflineSettings(): {
|
||||
enabled: boolean;
|
||||
localModelRouting: OfflineLocalModelRouting;
|
||||
} {
|
||||
return { ...this.offlineSettings };
|
||||
}
|
||||
|
||||
setApprovedPlanPath(path: string | undefined): void {
|
||||
this.approvedPlanPath = path;
|
||||
}
|
||||
@@ -2945,6 +2973,22 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.ideMode = value;
|
||||
}
|
||||
|
||||
async setOfflineMode(enabled: boolean): Promise<void> {
|
||||
if (this.offlineSettings.enabled === enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.offlineSettings.enabled = enabled;
|
||||
coreEvents.emitOfflineModeChanged(enabled);
|
||||
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.agentRegistry.reload();
|
||||
this.updateSystemInstructionIfInitialized();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current FileSystemService
|
||||
*/
|
||||
|
||||
@@ -72,6 +72,11 @@ describe('PromptProvider', () => {
|
||||
isInteractiveShellEnabled: vi.fn().mockReturnValue(true),
|
||||
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
|
||||
isMemoryManagerEnabled: vi.fn().mockReturnValue(false),
|
||||
isOfflineModeEnabled: vi.fn().mockReturnValue(false),
|
||||
getOfflineSettings: vi.fn().mockReturnValue({
|
||||
enabled: false,
|
||||
localModelRouting: 'stub_default_api',
|
||||
}),
|
||||
getSkillManager: vi.fn().mockReturnValue({
|
||||
getSkills: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
@@ -156,6 +161,39 @@ describe('PromptProvider', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should include offline strategy section when offline mode is enabled', () => {
|
||||
vi.mocked(mockConfig.isOfflineModeEnabled).mockReturnValue(true);
|
||||
vi.mocked(mockConfig.getOfflineSettings).mockReturnValue({
|
||||
enabled: true,
|
||||
localModelRouting: 'stub_default_api',
|
||||
});
|
||||
|
||||
const provider = new PromptProvider();
|
||||
const prompt = provider.getCoreSystemPrompt(mockConfig);
|
||||
|
||||
expect(prompt).toContain('# Offline Mode Strategy');
|
||||
expect(prompt).toContain('cloud_subagent');
|
||||
expect(prompt).toContain('stub_default_api');
|
||||
});
|
||||
|
||||
it('should omit offline strategy section when offline mode is disabled', () => {
|
||||
vi.mocked(mockConfig.isOfflineModeEnabled).mockReturnValue(false);
|
||||
|
||||
const provider = new PromptProvider();
|
||||
const prompt = provider.getCoreSystemPrompt(mockConfig);
|
||||
|
||||
expect(prompt).not.toContain('# Offline Mode Strategy');
|
||||
});
|
||||
|
||||
it('should not throw when tool registry is not initialized', () => {
|
||||
vi.mocked(mockConfig.getToolRegistry).mockReturnValue(
|
||||
undefined as unknown as ToolRegistry,
|
||||
);
|
||||
|
||||
const provider = new PromptProvider();
|
||||
expect(() => provider.getCoreSystemPrompt(mockConfig)).not.toThrow();
|
||||
});
|
||||
|
||||
describe('plan mode prompt', () => {
|
||||
const mockMessageBus = {
|
||||
publish: vi.fn(),
|
||||
|
||||
@@ -56,7 +56,8 @@ export class PromptProvider {
|
||||
const isPlanMode = approvalMode === ApprovalMode.PLAN;
|
||||
const isYoloMode = approvalMode === ApprovalMode.YOLO;
|
||||
const skills = context.config.getSkillManager().getSkills();
|
||||
const toolNames = context.toolRegistry.getAllToolNames();
|
||||
const toolRegistry = context.toolRegistry;
|
||||
const toolNames = toolRegistry?.getAllToolNames?.() ?? [];
|
||||
const enabledToolNames = new Set(toolNames);
|
||||
|
||||
const approvedPlanPath = context.config.getApprovedPlanPath();
|
||||
@@ -85,7 +86,7 @@ export class PromptProvider {
|
||||
// --- Context Gathering ---
|
||||
let planModeToolsList = '';
|
||||
if (isPlanMode) {
|
||||
const allTools = context.toolRegistry.getAllTools();
|
||||
const allTools = toolRegistry?.getAllTools?.() ?? [];
|
||||
planModeToolsList = allTools
|
||||
.map((t) => {
|
||||
if (t instanceof DiscoveredMCPTool) {
|
||||
@@ -129,6 +130,11 @@ export class PromptProvider {
|
||||
(!!userMemory.global?.trim() ||
|
||||
!!userMemory.extension?.trim() ||
|
||||
!!userMemory.project?.trim());
|
||||
const offlineModeEnabled =
|
||||
context.config.isOfflineModeEnabled?.() ?? false;
|
||||
const offlineSettings = context.config.getOfflineSettings?.() ?? {
|
||||
localModelRouting: 'stub_default_api',
|
||||
};
|
||||
|
||||
const options: snippets.SystemPromptOptions = {
|
||||
preamble: this.withSection('preamble', () => ({
|
||||
@@ -141,6 +147,14 @@ export class PromptProvider {
|
||||
contextFilenames,
|
||||
topicUpdateNarration: context.config.isTopicUpdateNarrationEnabled(),
|
||||
})),
|
||||
offlineMode: this.withSection(
|
||||
'offlineMode',
|
||||
() => ({
|
||||
cloudSubagentName: 'cloud_subagent',
|
||||
localModelRouting: offlineSettings.localModelRouting,
|
||||
}),
|
||||
offlineModeEnabled,
|
||||
),
|
||||
subAgents: this.withSection(
|
||||
'agentContexts',
|
||||
() =>
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
export interface SystemPromptOptions {
|
||||
preamble?: PreambleOptions;
|
||||
coreMandates?: CoreMandatesOptions;
|
||||
offlineMode?: OfflineModeOptions;
|
||||
subAgents?: SubAgentOptions[];
|
||||
agentSkills?: AgentSkillOptions[];
|
||||
hookContext?: boolean;
|
||||
@@ -109,6 +110,11 @@ export interface SubAgentOptions {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface OfflineModeOptions {
|
||||
cloudSubagentName: string;
|
||||
localModelRouting: string;
|
||||
}
|
||||
|
||||
// --- High Level Composition ---
|
||||
|
||||
/**
|
||||
@@ -121,6 +127,8 @@ ${renderPreamble(options.preamble)}
|
||||
|
||||
${renderCoreMandates(options.coreMandates)}
|
||||
|
||||
${renderOfflineMode(options.offlineMode)}
|
||||
|
||||
${renderSubAgents(options.subAgents)}
|
||||
${renderAgentSkills(options.agentSkills)}
|
||||
|
||||
@@ -216,6 +224,19 @@ For example:
|
||||
- A test-fixing-agent -> Should be used both for fixing tests as well as investigating test failures.`;
|
||||
}
|
||||
|
||||
export function renderOfflineMode(options?: OfflineModeOptions): string {
|
||||
if (!options) return '';
|
||||
return `
|
||||
# Offline Mode Strategy
|
||||
|
||||
- You are operating with **Offline Mode** enabled.
|
||||
- Handle simple work directly and delegate complex tasks to \`${options.cloudSubagentName}\`.
|
||||
- Use cloud delegation for high-volume output, speculative investigations, and long-running execution.
|
||||
- Cloud delegation should use the standard confirmation flow and include audit-friendly context.
|
||||
- Always include a brief delegation reason so the confirmation request can be audited.
|
||||
- Current local model routing mode: \`${options.localModelRouting}\` (stubbed to default API backend for now).`;
|
||||
}
|
||||
|
||||
export function renderAgentSkills(skills?: AgentSkillOptions[]): string {
|
||||
if (!skills || skills.length === 0) return '';
|
||||
const skillsXml = skills
|
||||
|
||||
@@ -43,6 +43,7 @@ import { DEFAULT_CONTEXT_FILENAME } from '../tools/memoryTool.js';
|
||||
export interface SystemPromptOptions {
|
||||
preamble?: PreambleOptions;
|
||||
coreMandates?: CoreMandatesOptions;
|
||||
offlineMode?: OfflineModeOptions;
|
||||
subAgents?: SubAgentOptions[];
|
||||
agentSkills?: AgentSkillOptions[];
|
||||
hookContext?: boolean;
|
||||
@@ -115,6 +116,11 @@ export interface SubAgentOptions {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface OfflineModeOptions {
|
||||
cloudSubagentName: string;
|
||||
localModelRouting: string;
|
||||
}
|
||||
|
||||
// --- High Level Composition ---
|
||||
|
||||
/**
|
||||
@@ -127,6 +133,8 @@ ${renderPreamble(options.preamble)}
|
||||
|
||||
${renderCoreMandates(options.coreMandates)}
|
||||
|
||||
${renderOfflineMode(options.offlineMode)}
|
||||
|
||||
${renderSubAgents(options.subAgents)}
|
||||
|
||||
${renderAgentSkills(options.agentSkills)}
|
||||
@@ -290,6 +298,20 @@ For example:
|
||||
- A test-fixing-agent -> Should be used both for fixing tests as well as investigating test failures.`.trim();
|
||||
}
|
||||
|
||||
export function renderOfflineMode(options?: OfflineModeOptions): string {
|
||||
if (!options) return '';
|
||||
return `
|
||||
# Offline Mode Strategy
|
||||
|
||||
- You are operating with **Offline Mode** enabled.
|
||||
- Treat your own thread as local-first: handle surgical or straightforward tasks directly.
|
||||
- Delegate complex, long-running, high-output, or highly exploratory work to \`${options.cloudSubagentName}\`.
|
||||
- Cloud delegation should use the standard confirmation flow and include audit-friendly context.
|
||||
- Every delegation MUST include a concise reason that explains why cloud delegation is justified.
|
||||
- Keep the main conversation lean by preferring delegation for work that would otherwise bloat context.
|
||||
- Current local model routing mode: \`${options.localModelRouting}\` (stubbed to default API backend for now).`.trim();
|
||||
}
|
||||
|
||||
export function renderAgentSkills(skills?: AgentSkillOptions[]): string {
|
||||
if (!skills || skills.length === 0) return '';
|
||||
const skillsXml = skills
|
||||
|
||||
@@ -52,6 +52,25 @@ export interface ModelChangedPayload {
|
||||
model: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the 'offline-mode-changed' event.
|
||||
*/
|
||||
export interface OfflineModeChangedPayload {
|
||||
/**
|
||||
* Whether offline mode is currently enabled.
|
||||
*/
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for cloud subagent execution lifecycle events.
|
||||
*/
|
||||
export interface CloudSubagentExecutionPayload {
|
||||
agentName: string;
|
||||
state: 'started' | 'ended';
|
||||
outcome?: 'success' | 'error' | 'cancelled';
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the 'console-log' event.
|
||||
*/
|
||||
@@ -181,6 +200,8 @@ export interface QuotaChangedPayload {
|
||||
export enum CoreEvent {
|
||||
UserFeedback = 'user-feedback',
|
||||
ModelChanged = 'model-changed',
|
||||
OfflineModeChanged = 'offline-mode-changed',
|
||||
CloudSubagentExecution = 'cloud-subagent-execution',
|
||||
ConsoleLog = 'console-log',
|
||||
Output = 'output',
|
||||
MemoryChanged = 'memory-changed',
|
||||
@@ -215,6 +236,8 @@ export interface EditorSelectedPayload {
|
||||
export interface CoreEvents extends ExtensionEvents {
|
||||
[CoreEvent.UserFeedback]: [UserFeedbackPayload];
|
||||
[CoreEvent.ModelChanged]: [ModelChangedPayload];
|
||||
[CoreEvent.OfflineModeChanged]: [OfflineModeChangedPayload];
|
||||
[CoreEvent.CloudSubagentExecution]: [CloudSubagentExecutionPayload];
|
||||
[CoreEvent.ConsoleLog]: [ConsoleLogPayload];
|
||||
[CoreEvent.Output]: [OutputPayload];
|
||||
[CoreEvent.MemoryChanged]: [MemoryChangedPayload];
|
||||
@@ -327,6 +350,24 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
|
||||
this.emit(CoreEvent.ModelChanged, payload);
|
||||
}
|
||||
|
||||
emitOfflineModeChanged(enabled: boolean): void {
|
||||
const payload: OfflineModeChangedPayload = { enabled };
|
||||
this.emit(CoreEvent.OfflineModeChanged, payload);
|
||||
}
|
||||
|
||||
emitCloudSubagentExecution(
|
||||
agentName: string,
|
||||
state: 'started' | 'ended',
|
||||
outcome?: 'success' | 'error' | 'cancelled',
|
||||
): void {
|
||||
const payload: CloudSubagentExecutionPayload = {
|
||||
agentName,
|
||||
state,
|
||||
outcome,
|
||||
};
|
||||
this.emit(CoreEvent.CloudSubagentExecution, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies subscribers that settings have been modified.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user