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.
This commit is contained in:
Mahima Shanware
2026-04-28 18:11:05 +00:00
parent aa821442fd
commit 58f1cdb24c
5 changed files with 122 additions and 16 deletions
@@ -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();
});
});
+35 -2
View File
@@ -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": <true if you used the User Knowledge Base below to answer this prompt, false otherwise>,
"new_rule": "<If used_knowledge is false and action is not <WAIT> or <DONE>, 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)');
@@ -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;
}
@@ -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 (
+7 -1
View File
@@ -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;
}
}