From 58f1cdb24cc7460176846569d57871c74553bc5c Mon Sep 17 00:00:00 2001 From: Mahima Shanware Date: Tue, 28 Apr 2026 18:11:05 +0000 Subject: [PATCH] feat(cli): inject internal tool state into UserSimulator and enhance UI noise suppression - Subscribe UserSimulator to MESSAGE_BUS for tool-calls-update events. - Explicitly notify simulator when the system is blocked awaiting tool approval. - Suppress Notifications and ToastDisplay when simulateUser is enabled. - Suppress warn/error logs in ConsolePatcher during non-interactive simulation. - Add 100ms post-submission settle delay for robust input injection. - Update unit tests to verify internal state injection and reliable delays. --- .../cli/src/services/UserSimulator.test.ts | 75 ++++++++++++++++--- packages/cli/src/services/UserSimulator.ts | 37 ++++++++- .../cli/src/ui/components/Notifications.tsx | 12 ++- .../cli/src/ui/components/ToastDisplay.tsx | 6 ++ packages/cli/src/ui/utils/ConsolePatcher.ts | 8 +- 5 files changed, 122 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/services/UserSimulator.test.ts b/packages/cli/src/services/UserSimulator.test.ts index 7e55326b71..9e4249006d 100644 --- a/packages/cli/src/services/UserSimulator.test.ts +++ b/packages/cli/src/services/UserSimulator.test.ts @@ -4,17 +4,25 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { UserSimulator } from './UserSimulator.js'; import { Writable } from 'node:stream'; -import type { Config } from '@google/gemini-cli-core'; +import { + type Config, + MessageBusType, + CoreToolCallStatus, +} from '@google/gemini-cli-core'; describe('UserSimulator', () => { let mockConfig: Config; - let mockGetScreen: vi.Mock<() => string | undefined>; + let mockGetScreen: Mock<() => string | undefined>; let mockStdinBuffer: Writable; let mockContentGenerator: { - generateContent: vi.Mock; + generateContent: Mock; + }; + let mockMessageBus: { + subscribe: Mock; + unsubscribe: Mock; }; beforeEach(() => { @@ -24,12 +32,18 @@ describe('UserSimulator', () => { .mockResolvedValue({ text: JSON.stringify({ action: 'y\r' }) }), }; + mockMessageBus = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + mockConfig = { getContentGenerator: () => mockContentGenerator, getSimulateUser: () => true, getQuestion: () => 'test goal', getKnowledgeSource: () => undefined, getHasAccessToPreviewModel: () => true, + getMessageBus: () => mockMessageBus, } as unknown as Config; mockGetScreen = vi.fn(); @@ -53,10 +67,6 @@ describe('UserSimulator', () => { 'Thinking... (0s)\n\nAction Required: Allow pip execution? [Y/n]', ); - // We need to trigger the private tick method. Since it's private and run on an interval, - // we can use a hack or just test the prompt construction if we refactor, - // but for now let's use the interval. - vi.useFakeTimers(); simulator.start(); @@ -122,7 +132,7 @@ describe('UserSimulator', () => { await vi.advanceTimersByTimeAsync(2000); // Wait for the async key submission loop to finish - // Initial delay 100ms + (3 chars * 10ms) = 130ms minimum + // Initial delay 100ms + (3 chars * 10ms) + 100ms settle = 230ms minimum await vi.advanceTimersByTimeAsync(500); expect(mockStdinBuffer.write).toHaveBeenCalledWith('a'); @@ -132,4 +142,51 @@ describe('UserSimulator', () => { simulator.stop(); vi.useRealTimers(); }); + + it('should inject internal tool state into the prompt', async () => { + const simulator = new UserSimulator( + mockConfig, + mockGetScreen, + mockStdinBuffer, + ); + mockGetScreen.mockReturnValue('Responding...'); + + vi.useFakeTimers(); + simulator.start(); + + // Verify subscription + expect(mockMessageBus.subscribe).toHaveBeenCalledWith( + MessageBusType.TOOL_CALLS_UPDATE, + expect.any(Function), + ); + + // Simulate tool call update + const handler = mockMessageBus.subscribe.mock.calls[0][1]; + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [ + { + status: CoreToolCallStatus.AwaitingApproval, + request: { name: 'test_tool' }, + }, + ], + }); + + // Trigger tick + await vi.advanceTimersByTimeAsync(2000); + + expect(mockContentGenerator.generateContent).toHaveBeenCalled(); + const lastCall = mockContentGenerator.generateContent.mock.calls[0]; + const prompt = lastCall[0].contents[0].parts[0].text; + + expect(prompt).toContain( + 'INTERNAL SYSTEM STATE: The system is currently BLOCKED', + ); + expect(prompt).toContain('test_tool'); + expect(prompt).toContain("Ignore any 'Responding' indicators"); + + simulator.stop(); + expect(mockMessageBus.unsubscribe).toHaveBeenCalled(); + vi.useRealTimers(); + }); }); diff --git a/packages/cli/src/services/UserSimulator.ts b/packages/cli/src/services/UserSimulator.ts index d2adae697d..4635ffb931 100644 --- a/packages/cli/src/services/UserSimulator.ts +++ b/packages/cli/src/services/UserSimulator.ts @@ -3,12 +3,16 @@ * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import type { Config } from '@google/gemini-cli-core'; import { debugLogger, LlmRole, PREVIEW_GEMINI_FLASH_MODEL, resolveModel, + MessageBusType, + CoreToolCallStatus, + type Config, + type ToolCall, + type ToolCallsUpdateMessage, } from '@google/gemini-cli-core'; import type { Writable } from 'node:stream'; import * as fs from 'node:fs'; @@ -31,6 +35,9 @@ export class UserSimulator { private knowledgeBase = ''; private editableKnowledgeFile: string | null = null; private actionHistory: string[] = []; + private pendingToolCalls: ToolCall[] = []; + private messageBusHandler: ((msg: ToolCallsUpdateMessage) => void) | null = + null; constructor( private readonly config: Config, @@ -42,6 +49,16 @@ export class UserSimulator { if (!this.config.getSimulateUser()) { return; } + + this.messageBusHandler = (msg: ToolCallsUpdateMessage) => { + this.pendingToolCalls = msg.toolCalls.filter( + (tc) => tc.status === CoreToolCallStatus.AwaitingApproval, + ); + }; + this.config + .getMessageBus() + .subscribe(MessageBusType.TOOL_CALLS_UPDATE, this.messageBusHandler); + const source = this.config.getKnowledgeSource?.(); if (source) { if (!fs.existsSync(source)) { @@ -66,6 +83,12 @@ export class UserSimulator { clearInterval(this.timer); this.timer = null; } + if (this.messageBusHandler) { + this.config + .getMessageBus() + .unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, this.messageBusHandler); + this.messageBusHandler = null; + } debugLogger.log('User simulator stopped'); } @@ -140,6 +163,12 @@ export class UserSimulator { .join('\n')}\n` : ''; + const pendingToolInstruction = + this.pendingToolCalls.length > 0 + ? `\nINTERNAL SYSTEM STATE: The system is currently BLOCKED awaiting user approval for the following tool(s): ${this.pendingToolCalls.map((tc) => tc.request.name).join(', ')}. +Ignore any 'Responding' indicators, spinners, or timers. You MUST provide a response (e.g., 'y\\r', '2\\r') to unblock the tool execution NOW.\n` + : ''; + const prompt = `You are evaluating a CLI agent by simulating a user sitting at the terminal. Look carefully at the screen and determine the CLI's current state: @@ -170,7 +199,7 @@ JSON FORMAT: "used_knowledge": , "new_rule": " or , formulate a single, clear, reusable one-line rule combining the question and your answer without using option numbers (e.g. 1, 2) that might change. For example: 'If asked to allow pip execution, always allow it.' or 'Automatically accept edits for snake game implementation.'>" } -${goalInstruction}${knowledgeInstruction}${historyInstruction} +${goalInstruction}${knowledgeInstruction}${historyInstruction}${pendingToolInstruction} Here is the current terminal screen output: @@ -332,6 +361,10 @@ ${strippedScreen} // while preventing UI state collisions during long simulated inputs. await new Promise((resolve) => setTimeout(resolve, 10)); } + + // Wait a bit to ensure Ink has processed the full input + await new Promise((resolve) => setTimeout(resolve, 100)); + this.lastScreenContent = normalizedScreen; } else { debugLogger.log('[SIMULATOR] Skipping (empty response)'); diff --git a/packages/cli/src/ui/components/Notifications.tsx b/packages/cli/src/ui/components/Notifications.tsx index 4753f8f6cb..04b3c1b316 100644 --- a/packages/cli/src/ui/components/Notifications.tsx +++ b/packages/cli/src/ui/components/Notifications.tsx @@ -8,6 +8,7 @@ import { Box, Text, useIsScreenReaderEnabled } from 'ink'; import { useEffect, useState, useMemo, useRef, useCallback } from 'react'; import { useAppContext } from '../contexts/AppContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; +import { useConfig } from '../contexts/ConfigContext.js'; import { theme } from '../semantic-colors.js'; import { StreamingState } from '../types.js'; import { UpdateNotification } from './UpdateNotification.js'; @@ -35,10 +36,12 @@ const screenReaderNudgeFilePath = path.join( const MAX_STARTUP_WARNING_SHOW_COUNT = 3; export const Notifications = () => { + const config = useConfig(); const { startupWarnings } = useAppContext(); const { initError, streamingState, updateInfo } = useUIState(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); + const showInitError = initError && streamingState !== StreamingState.Responding; @@ -128,10 +131,11 @@ export const Notifications = () => { }, [showScreenReaderNudge]); if ( - !showStartupWarnings && - !showInitError && - !updateInfo && - !showScreenReaderNudge + config.getSimulateUser() || + (!showStartupWarnings && + !showInitError && + !updateInfo && + !showScreenReaderNudge) ) { return null; } diff --git a/packages/cli/src/ui/components/ToastDisplay.tsx b/packages/cli/src/ui/components/ToastDisplay.tsx index 617c9bc7ed..b0e14e0377 100644 --- a/packages/cli/src/ui/components/ToastDisplay.tsx +++ b/packages/cli/src/ui/components/ToastDisplay.tsx @@ -9,6 +9,7 @@ import { Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { useUIState, type UIState } from '../contexts/UIStateContext.js'; import { useInputState, type InputState } from '../contexts/InputContext.js'; +import { useConfig } from '../contexts/ConfigContext.js'; import { TransientMessageType } from '../../utils/events.js'; export function shouldShowToast( @@ -29,6 +30,11 @@ export function shouldShowToast( export const ToastDisplay: React.FC = () => { const uiState = useUIState(); const inputState = useInputState(); + const config = useConfig(); + + if (config.getSimulateUser()) { + return null; + } if (uiState.ctrlCPressedOnce) { return ( diff --git a/packages/cli/src/ui/utils/ConsolePatcher.ts b/packages/cli/src/ui/utils/ConsolePatcher.ts index ddd26fca0b..b412d42fba 100644 --- a/packages/cli/src/ui/utils/ConsolePatcher.ts +++ b/packages/cli/src/ui/utils/ConsolePatcher.ts @@ -53,7 +53,13 @@ export class ConsolePatcher { // When it is non interactive mode, do not show info logging unless // it is debug mode. default to true if it is undefined. if (this.params.interactive === false) { - if ((type === 'info' || type === 'log') && !this.params.debugMode) { + if ( + (type === 'info' || + type === 'log' || + type === 'warn' || + type === 'error') && + !this.params.debugMode + ) { return; } }