Compare commits

..

1 Commits

Author SHA1 Message Date
Coco Sheng 80ff4bd39c feat: implement alternate buffer toggling and unit tests 2026-03-30 11:40:52 -04:00
25 changed files with 142 additions and 413 deletions
-61
View File
@@ -1,61 +0,0 @@
import { evaluate } from 'mathjs';
export interface CalculatorState {
display: string;
equation: string;
lastResult: string | null;
}
export const initialState: CalculatorState = {
display: '0',
equation: '',
lastResult: null,
};
export function handleInput(state: CalculatorState, input: string): CalculatorState {
if (/[0-9]/.test(input)) {
if (state.display === '0' || state.lastResult !== null) {
return { ...state, display: input, lastResult: null };
} else {
return { ...state, display: state.display + input };
}
} else if (input === '.') {
if (!state.display.includes('.')) {
return { ...state, display: state.display + '.' };
}
} else if (['+', '-', '*', '/'].includes(input)) {
return {
...state,
equation: `${state.display} ${input} `,
display: '0',
};
}
return state;
}
export function calculate(state: CalculatorState): CalculatorState {
try {
const fullEquation = state.equation + state.display;
const result = evaluate(fullEquation);
const resultStr = result.toString();
return {
display: resultStr,
equation: '',
lastResult: resultStr,
};
} catch (error) {
return {
display: 'Error',
equation: '',
lastResult: null,
};
}
}
export function deleteLast(state: CalculatorState): CalculatorState {
if (state.display.length > 1) {
return { ...state, display: state.display.slice(0, -1) };
} else {
return { ...state, display: '0' };
}
}
-26
View File
@@ -1867,32 +1867,6 @@ describe('loadCliConfig with admin.mcp.config', () => {
});
});
describe('loadCliConfig deepValidation', () => {
beforeEach(() => {
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
});
it('should enable deepValidation by default', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings();
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.isDeepValidationEnabled()).toBe(true);
});
it('should allow disabling deepValidation via settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
general: {
deepValidation: false,
},
});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.isDeepValidationEnabled()).toBe(false);
});
});
describe('loadCliConfig model selection', () => {
beforeEach(() => {
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
-1
View File
@@ -900,7 +900,6 @@ export async function loadCliConfig(
memoryBoundaryMarkers: settings.context?.memoryBoundaryMarkers,
importFormat: settings.context?.importFormat,
debugMode,
deepValidation: settings.general?.deepValidation ?? true,
question,
worktreeSettings,
-10
View File
@@ -236,16 +236,6 @@ const SETTINGS_SCHEMA = {
description: 'Enable DevTools inspector on launch.',
showInDialog: false,
},
deepValidation: {
type: 'boolean',
label: 'Deep Validation',
category: 'General',
requiresRestart: false,
default: true,
description:
'Run a subagent after the main agent finishes to perform final validation.',
showInDialog: true,
},
enableAutoUpdate: {
type: 'boolean',
label: 'Enable Auto Update',
+1
View File
@@ -534,6 +534,7 @@ export const mockAppState: AppState = {
};
const mockUIActions: UIActions = {
toggleAlternateBuffer: vi.fn(),
handleThemeSelect: vi.fn(),
closeThemeDialog: vi.fn(),
handleThemeHighlight: vi.fn(),
+58 -1
View File
@@ -68,8 +68,10 @@ import {
writeToStdout,
disableMouseEvents,
enterAlternateScreen,
exitAlternateScreen,
enableMouseEvents,
disableLineWrapping,
enableLineWrapping,
shouldEnterAlternateScreen,
startupProfiler,
SessionStartSource,
@@ -213,7 +215,7 @@ export const AppContainer = (props: AppContainerProps) => {
});
useMemoryMonitor(historyManager);
const isAlternateBuffer = config.getUseAlternateBuffer();
const [isAlternateBuffer, setIsAlternateBuffer] = useState(config.getUseAlternateBuffer());
const [corgiMode, setCorgiMode] = useState(false);
const [forceRerenderKey, setForceRerenderKey] = useState(0);
const [debugMessage, setDebugMessage] = useState<string>('');
@@ -1550,6 +1552,23 @@ Logging in with Google... Restarting Gemini CLI to continue.
type: TransientMessageType;
}>(WARNING_PROMPT_DURATION_MS);
const [shownBufferToggleHint, setShownBufferToggleHint] = useState(false);
useEffect(() => {
if (isAlternateBuffer) return;
const isLongHistory = historyManager.history.length > 15;
const isComplexPrompt = buffer.text.length > 200 || buffer.text.includes('\n');
if ((isLongHistory || isComplexPrompt) && !shownBufferToggleHint) {
showTransientMessage({
text: 'Tip: Press Alt+T to toggle full-screen mode for better scrolling/editing',
type: TransientMessageType.Hint
});
setShownBufferToggleHint(true);
}
}, [historyManager.history.length, buffer.text, isAlternateBuffer, shownBufferToggleHint, showTransientMessage]);
const {
isFolderTrustDialogOpen,
discoveryResults: folderDiscoveryResults,
@@ -1700,6 +1719,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
return true;
}
if (keyMatchers[Command.TOGGLE_BUFFER_MODE](key)) {
toggleAlternateBuffer();
return true;
}
if (keyMatchers[Command.QUIT](key)) {
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
// This should happen regardless of the count.
@@ -2204,8 +2228,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
};
}, [config, refreshStatic]);
const showIsAlternateBufferHint = (historyManager.history.length > 15 || buffer.text.length > 200 || buffer.text.includes('\n')) && !isAlternateBuffer;
const uiState: UIState = useMemo(
() => ({
isAlternateBuffer,
history: historyManager.history,
historyManager,
isThemeDialogOpen,
@@ -2331,6 +2358,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
adminSettingsChanged,
newAgents,
showIsExpandableHint,
showIsAlternateBufferHint,
hintMode:
config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems),
hintBuffer: '',
@@ -2457,6 +2485,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
adminSettingsChanged,
newAgents,
showIsExpandableHint,
showIsAlternateBufferHint,
isAlternateBuffer,
],
);
@@ -2465,6 +2495,31 @@ Logging in with Google... Restarting Gemini CLI to continue.
[setShowPrivacyNotice],
);
const toggleAlternateBuffer = useCallback(() => {
setIsAlternateBuffer(prev => {
const next = !prev;
if (next) {
enterAlternateScreen();
enableMouseEvents();
disableLineWrapping();
} else {
exitAlternateScreen();
disableMouseEvents();
enableLineWrapping();
writeToStdout('\x1b[2J\x1b[H');
}
process.stdout.emit('resize');
// Give a tick for resize to process, then trigger remount to force full redraw
setImmediate(() => {
refreshStatic();
setForceRerenderKey((prev) => prev + 1);
});
return next;
});
}, [setIsAlternateBuffer, refreshStatic, setForceRerenderKey]);
const uiActions: UIActions = useMemo(
() => ({
handleThemeSelect,
@@ -2476,6 +2531,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleEditorSelect,
exitEditorDialog,
exitPrivacyNotice,
toggleAlternateBuffer,
closeSettingsDialog,
closeModelDialog,
openAgentConfigDialog,
@@ -2614,6 +2670,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
config,
historyManager,
getPreferredEditor,
toggleAlternateBuffer,
],
);
@@ -142,4 +142,38 @@ describe('<StatusRow />', () => {
await waitUntilReady();
expect(lastFrame()).toContain('Tip: Test Tip');
});
it('renders buffer toggle hint when showIsAlternateBufferHint is true', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: false,
showTips: false,
showWit: false,
modeContentObj: null,
showMinimalContext: false,
});
const uiState: Partial<UIState> = {
...defaultUiState,
showIsAlternateBufferHint: true,
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('[Alt+T] Switch to Full Screen');
});
});
+6 -1
View File
@@ -206,7 +206,12 @@ export const StatusRow: React.FC<StatusRowProps> = ({
return uiState.currentTip;
}
// 2. Shortcut Hint (Fallback)
// 2. Buffer Toggle Hint
if (uiState.showIsAlternateBufferHint) {
return '[Alt+T] Switch to Full Screen';
}
// 3. Shortcut Hint (Fallback)
if (
settings.merged.ui.showShortcutsHint &&
!hideUiDetailsForSuggestions &&
@@ -205,6 +205,8 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
'\u03A9': 'z', // "Ω" Option+z
'\u00B8': 'Z', // "¸" Option+Shift+z
'\u2202': 'd', // "∂" delete word forward
'\u2020': 't', // "†" toggle full screen buffer
'\u00E5': 'a', // "å" Option+a for alternate buffer
};
function nonKeyboardEventFilter(
@@ -78,6 +78,7 @@ export interface UIActions {
setShortcutsHelpVisible: (visible: boolean) => void;
setCleanUiDetailsVisible: (visible: boolean) => void;
toggleCleanUiDetailsVisible: () => void;
toggleAlternateBuffer: () => void;
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
handleWarning: (message: string) => void;
setEmbeddedShellFocused: (value: boolean) => void;
@@ -118,6 +118,8 @@ export interface UIState {
isEditorDialogOpen: boolean;
showPrivacyNotice: boolean;
corgiMode: boolean;
isAlternateBuffer: boolean;
showIsAlternateBufferHint: boolean;
debugMessage: string;
quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean;
@@ -11,49 +11,47 @@ import {
isAlternateBufferEnabled,
} from './useAlternateBuffer.js';
import type { Config } from '@google/gemini-cli-core';
import { useUIState } from '../contexts/UIStateContext.js';
vi.mock('../contexts/ConfigContext.js', () => ({
useConfig: vi.fn(),
}));
vi.mock('../contexts/UIStateContext.js');
const mockUseConfig = vi.mocked(
await import('../contexts/ConfigContext.js').then((m) => m.useConfig),
);
const mockUseUIState = vi.mocked(useUIState);
describe('useAlternateBuffer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return false when config.getUseAlternateBuffer returns false', async () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>);
it('should return false when uiState.isAlternateBuffer is false', async () => {
mockUseUIState.mockReturnValue({
isAlternateBuffer: false,
} as unknown as ReturnType<typeof mockUseUIState>);
const { result } = await renderHook(() => useAlternateBuffer());
expect(result.current).toBe(false);
});
it('should return true when config.getUseAlternateBuffer returns true', async () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => true,
} as unknown as ReturnType<typeof mockUseConfig>);
it('should return true when uiState.isAlternateBuffer is true', async () => {
mockUseUIState.mockReturnValue({
isAlternateBuffer: true,
} as unknown as ReturnType<typeof mockUseUIState>);
const { result } = await renderHook(() => useAlternateBuffer());
expect(result.current).toBe(true);
});
it('should return the immutable config value, not react to settings changes', async () => {
const mockConfig = {
getUseAlternateBuffer: () => true,
} as unknown as ReturnType<typeof mockUseConfig>;
mockUseConfig.mockReturnValue(mockConfig);
it('should react to state changes', async () => {
mockUseUIState.mockReturnValue({
isAlternateBuffer: false,
} as unknown as ReturnType<typeof mockUseUIState>);
const { result, rerender } = await renderHook(() => useAlternateBuffer());
// Value should remain true even after rerender
expect(result.current).toBe(true);
expect(result.current).toBe(false);
mockUseUIState.mockReturnValue({
isAlternateBuffer: true,
} as unknown as ReturnType<typeof mockUseUIState>);
rerender();
@@ -4,14 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useConfig } from '../contexts/ConfigContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import type { Config } from '@google/gemini-cli-core';
export const isAlternateBufferEnabled = (config: Config): boolean =>
config.getUseAlternateBuffer();
// This is read from Config so that the UI reads the same value per application session
// This is read from UIState so that the UI can toggle dynamically
export const useAlternateBuffer = (): boolean => {
const config = useConfig();
return isAlternateBufferEnabled(config);
const uiState = useUIState();
return uiState.isAlternateBuffer;
};
+3
View File
@@ -95,6 +95,7 @@ export enum Command {
RESTART_APP = 'app.restart',
SUSPEND_APP = 'app.suspend',
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'app.showShellUnfocusWarning',
TOGGLE_BUFFER_MODE = 'app.toggleBufferMode',
// Background Shell Controls
BACKGROUND_SHELL_ESCAPE = 'background.escape',
@@ -392,6 +393,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
[Command.TOGGLE_BUFFER_MODE, [new KeyBinding('alt+a')]],
// Background Shell Controls
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
@@ -609,6 +611,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.',
[Command.TOGGLE_BUFFER_MODE]: 'Toggle between regular and full screen (alternate buffer) mode.',
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
[Command.CYCLE_APPROVAL_MODE]:
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.',
@@ -1,82 +0,0 @@
/**
* @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 type { LocalAgentDefinition } from './types.js';
const DeepValidationAgentSchema = z.object({
report: z.string().describe('The final validation report.'),
isSatisfied: z.boolean().describe('Whether the original prompt was fully satisfied.'),
});
/**
* A specialized subagent that performs final validation after the main agent finishes.
* It reflects on the original prompt and determines if it was satisfied by reviewing
* the changes and running the application and tests.
*/
export const DeepValidationAgent = (
context: AgentLoopContext,
originalPrompt: string,
): LocalAgentDefinition<typeof DeepValidationAgentSchema> => ({
kind: 'local',
name: 'deep-validation',
displayName: 'Deep Validation Agent',
internal: true,
description:
'A specialized subagent that performs final validation. It reflects on the original prompt and determines if it was satisfied by reviewing the changes and running the application and tests, if applicable.',
inputConfig: {
inputSchema: {
type: 'object',
properties: {
originalPrompt: {
type: 'string',
description: 'The original user request or prompt that was executed.',
},
},
required: ['originalPrompt'],
},
},
outputConfig: {
outputName: 'validationResult',
description: 'The final validation report and satisfaction status.',
schema: DeepValidationAgentSchema,
},
modelConfig: {
model: 'inherit',
},
get toolConfig() {
const tools = context.toolRegistry?.getAllToolNames() ?? [];
return {
tools,
};
},
get promptConfig() {
return {
systemPrompt: `You are the Deep Validation subagent. Your job is to perform final validation after the main agent finishes.
The user's original request was: ${originalPrompt}
You must deterministically determine if the request was satisfied.
Review the changes using git, check the code, and run the application, any existing linters, formatters, and tests if applicable.
Make a comprehensive and prioritized list of any validation failures and then fix them in priority order. Take care not to leave the code worse than you found it.
Use the following order to guide your prioritization of fixes:
- Unfulfilled aspects of the original request.
- Issues blocking correct operation of the solution, such as runtime failures, test failures, etc.
- Conventions
- Linters
- Best practices for the ecosystem.
Try to keep your changes targeted and as minimal as possible while still resolving the validation issue.
Return a final validation report detailing your findings and a boolean indicating if the request was fully satisfied.`,
query: `Please validate the original request: ${originalPrompt}`,
};
},
runConfig: {
maxTimeMinutes: 10,
maxTurns: 10,
},
});
+1 -1
View File
@@ -56,7 +56,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
* @param messageBus Message bus for policy enforcement.
*/
constructor(
private readonly definition: LocalAgentDefinition<any>,
private readonly definition: LocalAgentDefinition,
private readonly context: AgentLoopContext,
params: AgentInputs,
messageBus: MessageBus,
@@ -272,7 +272,6 @@ describe('AgentRegistry', () => {
codebase_investigator: { enabled: false },
cli_help: { enabled: false },
generalist: { enabled: false },
'deep-validation': { enabled: false },
},
},
});
+2 -4
View File
@@ -14,7 +14,6 @@ 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 { DeepValidationAgent } from './deep-validation-agent.js';
import { BrowserAgentDefinition } from './browser/browserAgentDefinition.js';
import { MemoryManagerAgent } from './memory-manager-agent.js';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
@@ -244,7 +243,7 @@ export class AgentRegistry {
if (this.config.getDebugMode()) {
debugLogger.log(
`[AgentRegistry] Loaded with ${this.getAllDefinitions().length} agents.`,
`[AgentRegistry] Loaded with ${this.agents.size} agents.`,
);
}
}
@@ -253,7 +252,6 @@ export class AgentRegistry {
this.registerLocalAgent(CodebaseInvestigatorAgent(this.config));
this.registerLocalAgent(CliHelpAgent(this.config));
this.registerLocalAgent(GeneralistAgent(this.config));
this.registerLocalAgent(DeepValidationAgent(this.config, '${originalPrompt}'));
// Register the browser agent if enabled in settings.
// Tools are configured dynamically at invocation time via browserAgentFactory.
@@ -671,7 +669,7 @@ export class AgentRegistry {
* Returns all active agent definitions.
*/
getAllDefinitions(): AgentDefinition[] {
return Array.from(this.agents.values()).filter((d) => !d.internal);
return Array.from(this.agents.values());
}
/**
-1
View File
@@ -193,7 +193,6 @@ export interface BaseAgentDefinition<
displayName?: string;
description: string;
experimental?: boolean;
internal?: boolean;
inputConfig: InputConfig;
outputConfig?: OutputConfig<TOutput>;
metadata?: {
+4 -63
View File
@@ -43,15 +43,7 @@ import { EnterPlanModeTool } from '../tools/enter-plan-mode.js';
import { GeminiClient } from '../core/client.js';
import { BaseLlmClient } from '../core/baseLlmClient.js';
import { LocalLiteRtLmClient } from '../core/localLiteRtLmClient.js';
import {
type HookDefinition,
HookEventName,
HookType,
type AfterAgentInput,
type HookInput,
} from '../hooks/types.js';
import { DeepValidationAgent } from '../agents/deep-validation-agent.js';
import { LocalSubagentInvocation } from '../agents/local-invocation.js';
import type { HookDefinition, HookEventName } from '../hooks/types.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { GitService } from '../services/gitService.js';
import {
@@ -583,7 +575,6 @@ export interface ConfigParameters {
toolSandboxing?: boolean;
targetDir: string;
debugMode: boolean;
deepValidation?: boolean;
question?: string;
coreTools?: string[];
@@ -749,7 +740,6 @@ export class Config implements McpContext, AgentLoopContext {
private readonly targetDir: string;
private workspaceContext: WorkspaceContext;
private readonly debugMode: boolean;
private readonly deepValidation: boolean;
private readonly question: string | undefined;
private readonly worktreeSettings: WorktreeSettings | undefined;
readonly enableConseca: boolean;
@@ -1019,7 +1009,6 @@ export class Config implements McpContext, AgentLoopContext {
this.workspaceContext = new WorkspaceContext(this.targetDir, []);
this.pendingIncludeDirectories = params.includeDirectories ?? [];
this.debugMode = params.debugMode;
this.deepValidation = params.deepValidation ?? false;
this.question = params.question;
this.worktreeSettings = params.worktreeSettings;
@@ -1454,53 +1443,10 @@ export class Config implements McpContext, AgentLoopContext {
}
}
// Initialize hook system if enabled or if deepValidation is enabled
if (this.getEnableHooks() || this.deepValidation) {
// Initialize hook system if enabled
if (this.getEnableHooks()) {
this.hookSystem = new HookSystem(this);
await this.hookSystem.initialize();
// Register Deep Validation hook if enabled
if (this.deepValidation) {
this.hookSystem.registerHook(
{
type: HookType.Runtime,
name: 'deep-validation-hook',
action: async (input: HookInput) => {
const afterAgentInput = input as AfterAgentInput;
const invocation = new LocalSubagentInvocation(
DeepValidationAgent(this, afterAgentInput.prompt),
this,
{ originalPrompt: afterAgentInput.prompt },
this.messageBus,
);
const result = await invocation.execute(new AbortController().signal);
const progress = result.returnDisplay as any;
const subagentResult = progress?.result;
let validationResult: { report: string; isSatisfied: boolean };
try {
validationResult = JSON.parse(subagentResult);
} catch {
validationResult = {
report: subagentResult || 'Failed to parse validation report.',
isSatisfied: false,
};
}
const satisfactionMessage = validationResult.isSatisfied
? '✅ Deep validation satisfied.'
: '⚠️ Deep validation NOT satisfied.';
return {
continue: true,
systemMessage: `${satisfactionMessage}\n\n${validationResult.report}`,
};
},
},
HookEventName.AfterAgent,
);
}
}
if (this.experimentalJitContext) {
@@ -2051,11 +1997,6 @@ export class Config implements McpContext, AgentLoopContext {
getDebugMode(): boolean {
return this.debugMode;
}
isDeepValidationEnabled(): boolean {
return this.deepValidation;
}
getQuestion(): string | undefined {
return this.question;
}
@@ -3567,7 +3508,7 @@ export class Config implements McpContext, AgentLoopContext {
const discoveredNames = this.agentRegistry.getAllDiscoveredAgentNames();
for (const agentName of discoveredNames) {
const definition = this.agentRegistry.getDiscoveredDefinition(agentName);
if (!definition || definition.internal) {
if (!definition) {
continue;
}
try {
@@ -1,121 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Config } from './config.js';
import { HookEventName } from '../hooks/types.js';
import { LocalSubagentInvocation } from '../agents/local-invocation.js';
vi.mock('../agents/local-invocation.js', () => ({
LocalSubagentInvocation: vi.fn(),
}));
vi.mock('../agents/deep-validation-agent.js', () => ({
DeepValidationAgent: vi.fn().mockReturnValue({
kind: 'local',
name: 'deep-validation',
displayName: 'Deep Validation Agent',
description: 'test description',
inputConfig: { inputSchema: {} },
outputConfig: { outputName: 'validationResult', schema: { safeParse: vi.fn() } },
promptConfig: { systemPrompt: 'test prompt', query: 'test query' },
modelConfig: { model: 'inherit' },
runConfig: {},
}),
}));
describe('Deep Validation Integration', () => {
let config: Config;
beforeEach(() => {
vi.clearAllMocks();
});
it('should register AfterAgent hook when deepValidation is enabled', async () => {
config = new Config({
sessionId: 'test-session',
targetDir: '/tmp',
debugMode: false,
cwd: '/tmp',
model: 'test-model',
enableHooks: true,
deepValidation: true,
} as any);
await config.initialize();
const hookSystem = config.getHookSystem();
expect(hookSystem).toBeDefined();
const hooks = hookSystem?.getAllHooks();
expect(hooks).toBeDefined();
expect(hooks?.some(h => h.config.name === 'deep-validation-hook' && h.eventName === HookEventName.AfterAgent)).toBe(true);
});
it('should NOT register AfterAgent hook when deepValidation is disabled', async () => {
config = new Config({
sessionId: 'test-session',
targetDir: '/tmp',
debugMode: false,
cwd: '/tmp',
model: 'test-model',
enableHooks: true,
deepValidation: false,
} as any);
await config.initialize();
const hookSystem = config.getHookSystem();
const hooks = hookSystem?.getAllHooks();
expect(hooks?.some(h => h.config.name === 'deep-validation-hook')).toBeFalsy();
});
it('should execute DeepValidationAgent when hook is triggered', async () => {
config = new Config({
sessionId: 'test-session',
targetDir: '/tmp',
debugMode: false,
cwd: '/tmp',
model: 'test-model',
enableHooks: true,
deepValidation: true,
} as any);
const mockExecute = vi.fn().mockResolvedValue({
returnDisplay: {
result: JSON.stringify({
report: 'All good',
isSatisfied: true,
}),
},
});
(LocalSubagentInvocation as any).mockImplementation(() => ({
execute: mockExecute,
}));
await config.initialize();
const hookSystem = config.getHookSystem();
const deepValidationEntry = hookSystem?.getAllHooks()?.find(h => h.config.name === 'deep-validation-hook');
expect(deepValidationEntry).toBeDefined();
if (deepValidationEntry && 'action' in deepValidationEntry.config) {
const result = await (deepValidationEntry.config as any).action({ prompt: 'original prompt' } as any);
expect(LocalSubagentInvocation).toHaveBeenCalled();
expect(mockExecute).toHaveBeenCalled();
expect(result).toMatchObject({
continue: true,
systemMessage: expect.stringContaining('✅ Deep validation satisfied.'),
});
expect(result).toMatchObject({
systemMessage: expect.stringContaining('All good'),
});
}
});
});
+2 -2
View File
@@ -809,7 +809,7 @@ export class GeminiClient {
// Update cumulative response in hook state
// We do this immediately after the stream finishes for THIS turn.
const hooksEnabled = this.config.getHookSystem() !== undefined;
const hooksEnabled = this.config.getEnableHooks();
if (hooksEnabled) {
const responseText = turn.getResponseText() || '';
const hookState = this.hookStateMap.get(prompt_id);
@@ -900,7 +900,7 @@ export class GeminiClient {
this.config.resetTurn();
}
const hooksEnabled = this.config.getHookSystem() !== undefined;
const hooksEnabled = this.config.getEnableHooks();
const messageBus = this.context.messageBus;
if (this.lastPromptId !== prompt_id) {
+1 -1
View File
@@ -70,7 +70,7 @@ describe('HookRegistry', () => {
mockConfig = {
storage: mockStorage,
getExtensions: vi.fn().mockReturnValue([]),
getHooks: vi.fn(), getEnableHooks: vi.fn().mockReturnValue(true).mockReturnValue({}),
getHooks: vi.fn().mockReturnValue({}),
getProjectHooks: vi.fn().mockReturnValue({}),
getDisabledHooks: vi.fn().mockReturnValue([]),
isTrustedFolder: vi.fn().mockReturnValue(true),
+1 -4
View File
@@ -73,10 +73,7 @@ export class HookRegistry {
(entry) => entry.source === ConfigSource.Runtime,
);
this.entries = [...runtimeHooks];
if (this.config.getEnableHooks()) {
this.processHooksFromConfig();
}
this.processHooksFromConfig();
debugLogger.debug(
`Hook registry initialized with ${this.entries.length} hook entries`,
-7
View File
@@ -77,13 +77,6 @@
"default": false,
"type": "boolean"
},
"deepValidation": {
"title": "Deep Validation",
"description": "Run a subagent after the main agent finishes to perform final validation.",
"markdownDescription": "Run a subagent after the main agent finishes to perform final validation. This subagent will reflect on the original prompt, review changes, and run tests to ensure satisfaction.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"enableAutoUpdate": {
"title": "Enable Auto Update",
"description": "Enable automatic updates.",