feat: add offline/hybrid mode with cloud subagent delegation

This commit is contained in:
Samee Zahid
2026-04-15 00:04:48 -07:00
parent fd481ffc25
commit 27f35c3358
27 changed files with 927 additions and 23 deletions
+40
View File
@@ -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;
+1
View File
@@ -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();
+38
View File
@@ -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,
+10
View File
@@ -433,6 +433,9 @@ export const AppContainer = (props: AppContainerProps) => {
);
const [currentModel, setCurrentModel] = useState(config.getModel());
const [isOfflineMode, setIsOfflineMode] = useState(
config.isOfflineModeEnabled(),
);
const [userTier, setUserTier] = useState<UserTierId | undefined>(undefined);
const [quotaStats, setQuotaStats] = useState<QuotaStats | undefined>(() => {
@@ -567,6 +570,9 @@ export const AppContainer = (props: AppContainerProps) => {
const handleModelChanged = () => {
setCurrentModel(config.getModel());
};
const handleOfflineModeChanged = (payload: { enabled: boolean }) => {
setIsOfflineMode(payload.enabled);
};
const handleQuotaChanged = (payload: {
remaining: number | undefined;
@@ -581,9 +587,11 @@ export const AppContainer = (props: AppContainerProps) => {
};
coreEvents.on(CoreEvent.ModelChanged, handleModelChanged);
coreEvents.on(CoreEvent.OfflineModeChanged, handleOfflineModeChanged);
coreEvents.on(CoreEvent.QuotaChanged, handleQuotaChanged);
return () => {
coreEvents.off(CoreEvent.ModelChanged, handleModelChanged);
coreEvents.off(CoreEvent.OfflineModeChanged, handleOfflineModeChanged);
coreEvents.off(CoreEvent.QuotaChanged, handleQuotaChanged);
};
}, [config]);
@@ -2493,6 +2501,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
queueErrorMessage,
showApprovalModeIndicator,
allowPlanMode,
isOfflineMode,
currentModel,
contextFileNames,
errorCount,
@@ -2604,6 +2613,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
queueErrorMessage,
showApprovalModeIndicator,
allowPlanMode,
isOfflineMode,
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),
}),
};
@@ -34,6 +34,7 @@ describe('<StatusRow />', () => {
contextFileNames: [],
showApprovalModeIndicator: ApprovalMode.DEFAULT,
allowPlanMode: false,
isOfflineMode: false,
renderMarkdown: true,
currentModel: 'gemini-3',
};
@@ -140,4 +141,38 @@ 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,
});
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');
});
});
@@ -411,6 +411,11 @@ export const StatusRow: React.FC<StatusRowProps> = ({
<RawMarkdownIndicator />
</Box>
)}
{uiState.isOfflineMode && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<Text color={theme.status.success}> offline</Text>
</Box>
)}
</>
) : (
showRow2Minimal &&
@@ -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:
'Delegating to cloud-subagent for cloud execution.\nReason: Complex task.\nTask: Analyze migration risks.',
};
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,44 @@ export const ToolConfirmationMessage: React.FC<
key: 'No, suggest changes (esc)',
});
} else if (confirmationDetails.type === 'info') {
const isCloudSubagentConfirmation =
toolName === 'cloud-subagent' || toolName === '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 +448,7 @@ export const ToolConfirmationMessage: React.FC<
allowPermanentApproval,
config,
isDiffingEnabled,
toolName,
]);
const availableBodyContentHeight = useCallback(() => {
@@ -157,6 +157,7 @@ export interface UIState {
queueErrorMessage: string | null;
showApprovalModeIndicator: ApprovalMode;
allowPlanMode: boolean;
isOfflineMode?: boolean;
currentModel: string;
contextFileNames: string[];
errorCount: number;
+29 -10
View File
@@ -21,6 +21,7 @@ export const useComposerStatus = () => {
const uiState = useUIState();
const quotaState = useQuotaState();
const settings = useSettings();
const isOfflineMode = Boolean(uiState.isOfflineMode);
const hasPendingToolConfirmation = useMemo(
() =>
@@ -64,22 +65,40 @@ 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) {
return isOfflineMode
? {
text: `${approvalModeIndicator.text} + offline`,
color: approvalModeIndicator.color,
}
: approvalModeIndicator;
}
if (isOfflineMode) {
return { text: 'offline', color: theme.status.success };
}
return null;
}, [
uiState.cleanUiDetailsVisible,
showLoadingIndicator,
uiState.activeHooks.length,
showApprovalModeIndicator,
isOfflineMode,
]);
const showMinimalContext = isContextUsageHigh(