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)');