mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-09 01:27:41 -07:00
feat: add user simulator and integrate into CLI
This commit is contained in:
@@ -97,6 +97,7 @@ export interface CliArgs {
|
||||
rawOutput: boolean | undefined;
|
||||
acceptRawOutputRisk: boolean | undefined;
|
||||
isCommand: boolean | undefined;
|
||||
simulateUser: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -298,6 +299,11 @@ export async function parseArguments(
|
||||
.option('accept-raw-output-risk', {
|
||||
type: 'boolean',
|
||||
description: 'Suppress the security warning when using --raw-output.',
|
||||
})
|
||||
.option('simulate-user', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Run the user simulation agent in the background for evaluation purposes.',
|
||||
}),
|
||||
)
|
||||
// Register MCP subcommands
|
||||
@@ -799,6 +805,7 @@ export async function loadCliConfig(
|
||||
approvalMode,
|
||||
disableYoloMode:
|
||||
settings.security?.disableYoloMode || settings.admin?.secureModeEnabled,
|
||||
simulateUser: !!argv.simulateUser,
|
||||
disableAlwaysAllow:
|
||||
settings.security?.disableAlwaysAllow ||
|
||||
settings.admin?.secureModeEnabled,
|
||||
|
||||
@@ -513,6 +513,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
simulateUser: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -9,7 +9,9 @@ import { render } from 'ink';
|
||||
import { basename } from 'node:path';
|
||||
import { AppContainer } from './ui/AppContainer.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { UserSimulator } from './services/UserSimulator.js';
|
||||
import { registerCleanup, setupTtyCheck } from './utils/cleanup.js';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import {
|
||||
type StartupWarning,
|
||||
type Config,
|
||||
@@ -132,6 +134,9 @@ export async function startInteractiveUI(
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const simulateUser = config.getSimulateUser();
|
||||
const simulatedStdin = new PassThrough({ encoding: 'utf8' });
|
||||
|
||||
const instance = render(
|
||||
process.env['DEBUG'] ? (
|
||||
<React.StrictMode>
|
||||
@@ -143,7 +148,8 @@ export async function startInteractiveUI(
|
||||
{
|
||||
stdout: inkStdout,
|
||||
stderr: inkStderr,
|
||||
stdin: process.stdin,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
stdin: (simulateUser ? simulatedStdin : process.stdin) as any,
|
||||
exitOnCtrlC: false,
|
||||
isScreenReaderEnabled: config.getScreenReader(),
|
||||
onRender: ({ renderTime }: { renderTime: number }) => {
|
||||
@@ -179,6 +185,12 @@ export async function startInteractiveUI(
|
||||
}
|
||||
});
|
||||
|
||||
if (simulateUser) {
|
||||
const simulator = new UserSimulator(config, instance, simulatedStdin);
|
||||
simulator.start();
|
||||
registerCleanup(() => simulator.stop());
|
||||
}
|
||||
|
||||
registerCleanup(() => instance.unmount());
|
||||
|
||||
registerCleanup(setupTtyCheck());
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { Config} from '@google/gemini-cli-core';
|
||||
import { debugLogger, LlmRole } from '@google/gemini-cli-core';
|
||||
import type { Writable } from 'node:stream';
|
||||
import type { Instance } from 'ink';
|
||||
|
||||
export class UserSimulator {
|
||||
private isRunning = false;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private lastScreenContent = '';
|
||||
private isProcessing = false;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly instance: Instance,
|
||||
private readonly stdinBuffer: Writable,
|
||||
) {}
|
||||
|
||||
start() {
|
||||
if (!this.config.getSimulateUser()) {
|
||||
return;
|
||||
}
|
||||
this.isRunning = true;
|
||||
this.timer = setInterval(() => this.tick(), 2000);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isRunning = false;
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
debugLogger.log('User simulator stopped');
|
||||
}
|
||||
|
||||
private async tick() {
|
||||
if (!this.isRunning || this.isProcessing) return;
|
||||
|
||||
try {
|
||||
this.isProcessing = true;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
|
||||
const screen = (this.instance as any).lastFrame() as string | undefined;
|
||||
if (!screen || screen === this.lastScreenContent) return;
|
||||
|
||||
const strippedScreen = screen.replace(
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
|
||||
'',
|
||||
);
|
||||
|
||||
const contentGenerator = this.config.getContentGenerator();
|
||||
if (!contentGenerator) return;
|
||||
|
||||
const prompt = `You are evaluating a CLI agent by simulating a user sitting at the terminal.
|
||||
Here is the current terminal screen output:
|
||||
|
||||
<screen>
|
||||
${strippedScreen}
|
||||
</screen>
|
||||
|
||||
Look at the screen. Is the CLI waiting for your input (e.g., asking for tool confirmation, presenting a multi-choice question, or waiting for a text prompt with an indicator like "❯")?
|
||||
If it is NOT waiting for input (e.g., it is streaming a response or showing a spinner), you MUST output exactly: <WAIT>
|
||||
|
||||
If it IS waiting for your input, output ONLY the exact raw characters you would type.
|
||||
Read the screen context carefully. Sometimes you may need to choose a numbered option, answer yes/no, or provide a text explanation.
|
||||
For example:
|
||||
- To choose an option from a multi-choice list, output the number: 1
|
||||
- To answer a simple yes/no confirmation, output: y
|
||||
- To provide an explanation or answer an open-ended question, output the text followed by \n: Because it is required for the project\n
|
||||
- To enter a new prompt, output the text followed by \n.
|
||||
Do NOT output markdown, explanations of your thought process, or quotes. Output ONLY the raw characters to send or <WAIT>.`;
|
||||
|
||||
const response = await contentGenerator.generateContent(
|
||||
{
|
||||
model: this.config.getModel(),
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }],
|
||||
},
|
||||
],
|
||||
},
|
||||
'simulator-prompt',
|
||||
LlmRole.UTILITY_SIMULATOR,
|
||||
);
|
||||
|
||||
const responseText = (response.text || '').trim();
|
||||
if (responseText && responseText !== '<WAIT>') {
|
||||
const keys = responseText.replace(/\\n/g, '\n');
|
||||
this.stdinBuffer.write(keys);
|
||||
this.lastScreenContent = screen;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
debugLogger.error('UserSimulator tick failed', e);
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user