Compare commits

...

14 Commits

Author SHA1 Message Date
Your Name 4874bedbef Modify UserSimulator prompt to prefer 'Allow for this session' for execution prompts 2026-04-20 23:59:04 +00:00
Hadi Minooei d6a1864961 feat: add option to disable streaming in CLI 2026-04-20 12:24:27 -07:00
Your Name 1c963345a4 Fix regex gap in UserSimulator to handle 'Xm' timer format 2026-04-20 18:59:15 +00:00
Your Name 268970671a Improve screen normalization in UserSimulator to prevent polling loops 2026-04-20 18:52:05 +00:00
Your Name e879100f07 Fix race condition in terminal rendering in UserSimulator 2026-04-20 17:09:23 +00:00
Your Name 7a71df31ff Add debug logging to submission flow in InputPrompt 2026-04-20 16:02:34 +00:00
Your Name 76a5e4f87f Add debug logging to handleFinalSubmit in AppContainer 2026-04-20 16:02:31 +00:00
Your Name 10ade0306b Add debug logging to usePlanContent in ExitPlanModeDialog 2026-04-20 16:01:35 +00:00
Your Name 73e319324f Fix keypress handling when simulateUser is enabled and add debug logging 2026-04-20 16:01:34 +00:00
Your Name f0b49261a5 Add debug logging for keypresses in TextInput 2026-04-20 16:01:33 +00:00
Your Name 0e3d2fb880 Add debug logging for input keys in InputPrompt 2026-04-20 16:01:32 +00:00
Hadi Minooei bb8565ee9d fix(build): handle file exists error in build_package 2026-04-16 22:10:27 -07:00
Hadi Minooei bfbdae8e3a Merge remote-tracking branch 'origin/main' into feature/simulator-knowledge-update
# Conflicts:
#	package-lock.json
#	package.json
#	packages/cli/src/interactiveCli.tsx
#	packages/core/src/telemetry/llmRole.ts
2026-04-16 22:05:57 -07:00
Hadi Minooei 40f9db30ce feat(cli): add user simulator, docker support, and external knowledge source handling
- Added UserSimulator service for automated interactions.

- Added Docker simulation script and documentation.

- Supported --knowledge-source flag defaulting to ~/.agents/kb.md.
2026-04-16 19:16:39 -07:00
24 changed files with 1196 additions and 84 deletions
+111
View File
@@ -0,0 +1,111 @@
# Running User Simulation in Docker with External Knowledge Source
This guide explains how to run the User Simulator in a Docker environment while
mounting an external knowledge base. This setup allows the simulator to "learn"
from its interactions and persist that knowledge back to your host machine.
We have provided an automated script that handles the entire setup, execution,
and cleanup process.
## Prerequisites
- **Docker** installed and running.
- **Gemini API Key** (standard `AIza...` key).
- Local checkout of the `gemini-cli` repository.
## Execution via Automation Script (Recommended)
The easiest and most reliable way to run the simulation is using the provided
bash script. This script automatically:
1. Creates a uniquely timestamped workspace folder on your host.
2. Generates a global `settings.json` file to natively bypass the CLI's
interactive Folder Trust and Authentication dialogs.
3. Builds the sandbox image from your current branch.
4. Mounts the workspace and runs the container with `--init` to gracefully
handle termination (e.g., `Ctrl+C`).
### Running the Script
Ensure your API key is exported:
```bash
export GEMINI_API_KEY="AIzaSy..."
```
Run the script from the root of the repository:
```bash
# Uses the default prompt ("make a snake game in python")
./scripts/run_simulator_docker.sh
# Or, provide a custom prompt:
./scripts/run_simulator_docker.sh "create a simple react counter component"
```
## Manual Execution Breakdown
If you need to run the simulation manually, here is exactly what the automated
script does under the hood:
### 1. Prepare Workspace & Knowledge Source
```bash
WORKSPACE_DIR="/tmp/gemini_docker_workspace"
mkdir -p "$WORKSPACE_DIR"
touch "$WORKSPACE_DIR/knowledge.md"
chmod -R 777 "$WORKSPACE_DIR"
```
### 2. Bypass Interactive Startup Dialogs
To prevent the simulator from getting stuck on the initial Auth or Folder Trust
screens, generate a global `settings.json` file.
```bash
mkdir -p "$WORKSPACE_DIR/.gemini"
echo '{
"security": {
"auth": { "selectedType": "gemini-api-key" },
"folderTrust": { "enabled": false }
}
}' > "$WORKSPACE_DIR/.gemini/settings.json"
chmod 777 "$WORKSPACE_DIR/.gemini/settings.json"
```
### 3. Build the Image
```bash
GEMINI_SANDBOX=docker npm run build:sandbox -- -i gemini-cli-simulator:latest
```
### 4. Run the Container
Notice the `--init` flag (for `Ctrl+C` support) and the explicit mount mapping
the `settings.json` file into `/home/node/.gemini/` inside the container.
```bash
docker run -it --rm --init \
-v "$WORKSPACE_DIR:/workspace" \
-v "$WORKSPACE_DIR/.gemini/settings.json:/home/node/.gemini/settings.json" \
-w /workspace \
-e GEMINI_API_KEY="$GEMINI_API_KEY" \
-e GEMINI_DEBUG_LOG_FILE="/workspace/debug.log" \
gemini-cli-simulator:latest \
gemini --prompt-interactive "make a snake game in python" \
--approval-mode plan \
--simulate-user \
--knowledge-source "/workspace/knowledge.md"
```
## Verification
Once the simulation completes, verify the results in your workspace folder:
1. **Generated Code:** Check for project files (e.g., `snake.py`).
2. **Persistent Knowledge:** Check `knowledge.md`. You should see new rules
dynamically appended by the simulator.
3. **Logs:**
- `debug.log`: Detailed internal LLM decision logic.
- `interactions_<timestamp>.txt`: Raw screen scrape frames seen by the
simulator's "eyes".
+416 -60
View File
@@ -4593,56 +4593,6 @@
}
}
},
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz",
"integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.47.0",
"@typescript-eslint/visitor-keys": "8.47.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz",
"integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz",
"integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.47.0",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
@@ -4752,17 +4702,17 @@
}
},
"node_modules/@vscode/vsce": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.6.0.tgz",
"integrity": "sha512-u2ZoMfymRNJb14aHNawnXJtXHLXDVKc1oKZaH4VELKT/9iWKRVgtQOdwxCgtwSxJoqYvuK4hGlBWQJ05wxADhg==",
"version": "3.9.0",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/@vscode/vsce/-/vsce-3.9.0.tgz",
"integrity": "sha512-Dfql2kgPHpTKS+G4wTqYBKzK1KqX2gMxTC9dpnYge+aIZL+thh1Z6GXs4zOtNwo7DCPmS7BCfaHUAmNLxKiXkw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@azure/identity": "^4.1.0",
"@secretlint/node": "^10.1.1",
"@secretlint/secretlint-formatter-sarif": "^10.1.1",
"@secretlint/secretlint-rule-no-dotenv": "^10.1.1",
"@secretlint/secretlint-rule-preset-recommend": "^10.1.1",
"@secretlint/node": "^10.1.2",
"@secretlint/secretlint-formatter-sarif": "^10.1.2",
"@secretlint/secretlint-rule-no-dotenv": "^10.1.2",
"@secretlint/secretlint-rule-preset-recommend": "^10.1.2",
"@vscode/vsce-sign": "^2.0.0",
"azure-devops-node-api": "^12.5.0",
"chalk": "^4.1.2",
@@ -4779,13 +4729,13 @@
"minimatch": "^3.0.3",
"parse-semver": "^1.1.1",
"read": "^1.0.7",
"secretlint": "^10.1.1",
"secretlint": "^10.1.2",
"semver": "^7.5.2",
"tmp": "^0.2.3",
"typed-rest-client": "^1.8.4",
"url-join": "^4.0.1",
"xml2js": "^0.5.0",
"yauzl": "^2.3.1",
"yauzl": "^3.2.1",
"yazl": "^2.2.2"
},
"bin": {
@@ -4943,6 +4893,54 @@
"win32"
]
},
"node_modules/@vscode/vsce/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@vscode/vsce/node_modules/glob": {
"version": "11.1.0",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/glob/-/glob-11.1.0.tgz",
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.1.1",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@vscode/vsce/node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@@ -4982,6 +4980,30 @@
"node": ">=4"
}
},
"node_modules/@vscode/vsce/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@vscode/vsce/node_modules/minimatch/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@vscode/vsce/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
@@ -4989,6 +5011,20 @@
"dev": true,
"license": "ISC"
},
"node_modules/@vscode/vsce/node_modules/yauzl": {
"version": "3.3.0",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/yauzl/-/yauzl-3.3.0.tgz",
"integrity": "sha512-PtGEvEP30p7sbIBJKUBjUnqgTVOyMURc4dLo9iNyAJnNIEz9pm88cCXF21w94Kg3k6RXkeZh5DHOGS0qEONvNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
"pend": "~1.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz",
@@ -5744,6 +5780,19 @@
"url": "https://bevry.me/fund"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -5823,6 +5872,32 @@
"node": ">=8"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true,
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
@@ -6479,6 +6554,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
@@ -6804,6 +6886,23 @@
}
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -7173,6 +7272,17 @@
"node": ">=0.10.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@@ -8265,6 +8375,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"dev": true,
"license": "(MIT OR WTFPL)",
"optional": true,
"engines": {
"node": ">=6"
}
},
"node_modules/expand-tilde": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
@@ -8817,6 +8938,14 @@
"node": ">= 0.8"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/fs-extra": {
"version": "11.3.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz",
@@ -9074,6 +9203,14 @@
"node": ">= 14"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/glob": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz",
@@ -9726,6 +9863,28 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -10967,6 +11126,19 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/keytar": {
"version": "7.9.0",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/keytar/-/keytar-7.9.0.tgz",
"integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^4.3.0",
"prebuild-install": "^7.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -11706,6 +11878,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
@@ -11770,6 +11956,14 @@
"node": ">=10"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/mnemonist": {
"version": "0.40.3",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
@@ -11941,6 +12135,14 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -11966,6 +12168,28 @@
"node": ">= 0.4.0"
}
},
"node_modules/node-abi": {
"version": "3.89.0",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/node-abi/-/node-abi-3.89.0.tgz",
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-addon-api": {
"version": "4.3.0",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/node-addon-api/-/node-addon-api-4.3.0.tgz",
"integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -13111,6 +13335,75 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prebuild-install/node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"dev": true,
"license": "ISC",
"optional": true
},
"node_modules/prebuild-install/node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/prebuild-install/node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -14550,6 +14843,55 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true,
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/simple-git": {
"version": "3.33.0",
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.33.0.tgz",
@@ -15913,6 +16255,20 @@
"node": ">=0.6.11 <=0.7.0 || >=0.7.3"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -18393,7 +18749,7 @@
"@types/vscode": "^1.99.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vscode/vsce": "^3.6.0",
"@vscode/vsce": "^3.7.1",
"esbuild": "^0.25.3",
"eslint": "^9.25.1",
"npm-run-all2": "^8.0.2",
+37
View File
@@ -278,6 +278,24 @@ describe('parseArguments', () => {
});
});
describe('knowledgeSource', () => {
it('should parse --knowledge-source flag with a path', async () => {
process.argv = ['node', 'script.js', '--knowledge-source', 'mykb.md'];
const settings = createTestMergedSettings();
const argv = await parseArguments(settings);
expect(argv.knowledgeSource).toBe('mykb.md');
});
it('should default to ~/.agents/kb.md when --knowledge-source is provided without a path', async () => {
process.argv = ['node', 'script.js', '--knowledge-source'];
const settings = createTestMergedSettings();
const argv = await parseArguments(settings);
expect(argv.knowledgeSource).toBe(
path.join(os.homedir(), '.agents', 'kb.md'),
);
});
});
it.each([
{
description: 'long flags',
@@ -909,6 +927,25 @@ describe('loadCliConfig', () => {
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should enable simulateUser when knowledgeSource is provided', async () => {
process.argv = ['node', 'script.js', '--knowledge-source', 'k.txt'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings();
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getSimulateUser()).toBe(true);
expect(config.getKnowledgeSource()).toBe(
path.resolve(process.cwd(), 'k.txt'),
);
});
it('should enable simulateUser when simulateUser flag is provided', async () => {
process.argv = ['node', 'script.js', '--simulate-user'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings();
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getSimulateUser()).toBe(true);
});
it('should be non-interactive when isCommand is set', async () => {
process.argv = ['node', 'script.js', 'mcp', 'list'];
const argv = await parseArguments(createTestMergedSettings());
+31
View File
@@ -8,6 +8,7 @@ import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import process from 'node:process';
import * as path from 'node:path';
import * as os from 'node:os';
import { execa } from 'execa';
import { mcpCommand } from '../commands/mcp.js';
import { extensionsCommand } from '../commands/extensions.js';
@@ -79,6 +80,7 @@ export interface CliArgs {
model: string | undefined;
sandbox: boolean | string | undefined;
debug: boolean | undefined;
disableStreaming?: boolean;
prompt: string | undefined;
promptInteractive: string | undefined;
worktree?: string;
@@ -106,6 +108,8 @@ export interface CliArgs {
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
isCommand: boolean | undefined;
simulateUser: boolean | undefined;
knowledgeSource: string | undefined;
}
/**
@@ -418,6 +422,10 @@ export async function parseArguments(
type: 'boolean',
description: 'Enable screen reader mode for accessibility.',
})
.option('disable-streaming', {
type: 'boolean',
description: 'Disable streaming responses from the model',
})
.option('output-format', {
alias: 'o',
type: 'string',
@@ -443,6 +451,24 @@ 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.',
})
.option('knowledge-source', {
type: 'string',
skipValidation: true,
description:
'A file path to load into the user simulator context and update with new knowledge. Defaults to ~/.agents/kb.md if passed without a value.',
coerce: (value: string): string => {
const trimmed = value.trim();
if (trimmed === '') {
return path.join(os.homedir(), '.agents', 'kb.md');
}
return trimmed;
},
}),
)
.version(await getVersion()) // This will enable the --version flag based on package.json
@@ -897,6 +923,7 @@ export async function loadCliConfig(
return new Config({
acpMode: isAcpMode,
clientName,
disableStreaming: argv.disableStreaming,
sessionId,
clientVersion: await getVersion(),
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
@@ -948,6 +975,10 @@ export async function loadCliConfig(
approvalMode,
disableYoloMode:
settings.security?.disableYoloMode || settings.admin?.secureModeEnabled,
simulateUser: !!argv.simulateUser || !!argv.knowledgeSource,
knowledgeSource: argv.knowledgeSource
? path.resolve(cwd, resolvePath(argv.knowledgeSource))
: undefined,
disableAlwaysAllow:
settings.security?.disableAlwaysAllow ||
settings.admin?.secureModeEnabled,
+4
View File
@@ -555,6 +555,8 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
simulateUser: undefined,
knowledgeSource: undefined,
});
await act(async () => {
@@ -613,6 +615,8 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
simulateUser: undefined,
knowledgeSource: undefined,
});
await act(async () => {
+41 -4
View File
@@ -9,11 +9,19 @@ 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,
removeCleanup,
setupTtyCheck,
} from './utils/cleanup.js';
import { PassThrough } from 'node:stream';
interface RenderMetrics {
renderTime: number;
output: string;
staticOutput?: string;
}
import {
type StartupWarning,
type Config,
@@ -135,6 +143,12 @@ export async function startInteractiveUI(
// Wait a moment for shpool to stabilize terminal size and state.
await new Promise((resolve) => setTimeout(resolve, 100));
}
const simulateUser = config.getSimulateUser();
const simulatedStdin = new PassThrough({ encoding: 'utf8' });
let lastFrame: string | undefined;
const staticHistory: string[] = [];
const instance = render(
process.env['DEBUG'] ? (
<React.StrictMode>
@@ -146,12 +160,20 @@ 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 }) => {
if (renderTime > SLOW_RENDER_MS) {
recordSlowRender(config, renderTime);
onRender: (metrics: RenderMetrics) => {
lastFrame = metrics.output;
if (metrics.staticOutput) {
staticHistory.push(metrics.staticOutput);
if (staticHistory.length > 50) {
staticHistory.shift();
}
}
if (metrics.renderTime > SLOW_RENDER_MS) {
recordSlowRender(config, metrics.renderTime);
}
profiler.reportFrameRendered();
},
@@ -188,6 +210,21 @@ export async function startInteractiveUI(
}
});
if (simulateUser) {
const simulator = new UserSimulator(
config,
() => {
if (lastFrame === undefined) return undefined;
// Combine history with latest frame for the simulator
const historyText = staticHistory.join('\n');
return historyText ? `${historyText}\n${lastFrame}` : lastFrame;
},
simulatedStdin,
);
simulator.start();
registerCleanup(() => simulator.stop());
}
const cleanupUnmount = () => instance.unmount();
registerCleanup(cleanupUnmount);
+352
View File
@@ -0,0 +1,352 @@
/**
* @license
* 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,
} from '@google/gemini-cli-core';
import type { Writable } from 'node:stream';
import * as fs from 'node:fs';
import * as path from 'node:path';
interface SimulatorResponse {
action?: string;
thought?: string;
used_knowledge?: boolean;
new_rule?: string;
}
export class UserSimulator {
private isRunning = false;
private timer: NodeJS.Timeout | null = null;
private lastScreenContent = '';
private isProcessing = false;
private interactionsFile: string | null = null;
private knowledgeBase = '';
private editableKnowledgeFile: string | null = null;
private actionHistory: string[] = [];
constructor(
private readonly config: Config,
private readonly getScreen: () => string | undefined,
private readonly stdinBuffer: Writable,
) {}
start() {
if (!this.config.getSimulateUser()) {
return;
}
const source = this.config.getKnowledgeSource?.();
if (source) {
if (!fs.existsSync(source)) {
try {
fs.mkdirSync(path.dirname(source), { recursive: true });
fs.writeFileSync(source, '', 'utf8');
} catch (e) {
debugLogger.error(`Failed to create knowledge file at ${source}`, e);
}
}
this.editableKnowledgeFile = source;
this.loadKnowledge(source);
}
this.interactionsFile = `interactions_${Date.now()}.txt`;
this.isRunning = true;
this.timer = setInterval(() => this.tick(), 1000);
}
stop() {
this.isRunning = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
debugLogger.log('User simulator stopped');
}
private loadKnowledge(p: string) {
try {
if (!fs.existsSync(p)) return;
const stats = fs.statSync(p);
if (stats.isFile()) {
const content = fs.readFileSync(p, 'utf-8');
if (content.trim()) {
this.knowledgeBase = content + '\n';
}
}
} catch (e) {
debugLogger.error(`Failed to load knowledge from ${p}`, e);
}
}
private async tick() {
if (!this.isRunning || this.isProcessing) return;
try {
this.isProcessing = true;
const screen = this.getScreen();
if (!screen) 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,
'',
)
.replace(/\n([ \t]*\n)+/g, '\n\n');
const normalizedScreen = strippedScreen
.replace(/[\u2800-\u28FF]/g, '')
.replace(/[|/-\\]/g, '')
.replace(/\b\d+(\.\d+)?s\b/g, '')
.replace(/\b\d+m(\s+\d+s)?\b/g, '')
.replace(/\(\s*\)/g, '')
.trim();
if (normalizedScreen === this.lastScreenContent) return;
debugLogger.log(
`[SIMULATOR] Screen Content Seen:\n---\n${strippedScreen}\n---`,
);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Screen Content Seen:\n---\n${strippedScreen}\n---\n\n`,
);
}
const contentGenerator = this.config.getContentGenerator();
if (!contentGenerator) return;
const originalGoal = this.config.getQuestion();
const goalInstruction = originalGoal
? `\nThe original goal was: "${originalGoal}"\n`
: '';
const knowledgeInstruction = this.knowledgeBase
? `\nUser Knowledge Base:\nUse this information to answer questions if applicable. If the answer is not here, respond as you normally would.\n${this.knowledgeBase}\n`
: '';
const historyInstruction =
this.actionHistory.length > 0
? `\nRecent Simulator Actions (last 10):\n${this.actionHistory
.slice(-10)
.map((a, i) => `${i + 1}. ${JSON.stringify(a)}`)
.join('\n')}\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:
STATE 1: The agent is busy (e.g., streaming a response, showing a spinner, running a tool, or displaying a timer like "7s"). It is actively working and NOT waiting for text input.
- In this case, your action MUST be exactly: <WAIT>
STATE 2: The agent is waiting for you to authorize a tool, confirm an action, or answer a specific multi-choice question (e.g., "Action Required", "Allow execution", numbered options).
- In this case, your action MUST be the exact raw characters to select the option and submit it (e.g., 1\\r, 2\\r, y\\r, n\\r, or just \\r if the default option is acceptable). Do NOT output <DONE> or "Thank you". You must unblock the agent and allow it to run the tool.
STATE 3: The agent has finished its current thought process AND is idle, waiting for a NEW general text prompt (usually indicated by a "> Type your message" prompt).
- First, verify that the ACTUAL task is fully complete based on your original goal. Do not stop at intermediate steps like planning or syntax checking.
- If the task is indeed fully complete, your action should be "Thank you\\r" to graciously finish the simulation.
- If you have already said thank you, your action MUST be exactly: <DONE>
- If the agent is waiting at a general text prompt but the original task is NOT complete, provide text instructions to continue what is missing. DO NOT repeat the original goal if it has already been provided once. Ask it to continue or provide feedback based on the current state or send <DONE> if you think the task is completed.
STATE 4: Any other situation where the agent is waiting for text input or needs to press Enter.
- Your action should be the raw characters you would type, followed by \\r. For just an Enter key press, output \\r.
CRITICAL RULES:
- RULE 1: If there is ANY active spinner (e.g., ⠋, ⠙, ⠹, ⠸, ⠼, ⠴, ⠧) or an elapsed time indicator (e.g., "0s", "7s") anywhere on the screen, the agent is STILL WORKING. Your action MUST be <WAIT>. Do NOT issue commands, even if a text prompt is visible below it.
- RULE 2: If there is an "Action Required" or confirmation prompt on the screen, YOU MUST HANDLE IT (State 2). This takes precedence over everything else.
- RULE 3: If prompted to allow execution of a command with options like 'Allow once' and 'Allow for this session', you MUST choose the option for 'Allow for this session' (typically by sending '2\\r').
- RULE 4: You MUST output a strictly formatted JSON object with no markdown wrappers or extra text.
JSON FORMAT:
{
"action": "<The exact raw characters to send, <WAIT>, or <DONE>>",
"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}
Here is the current terminal screen output:
<screen>
${strippedScreen}
</screen>`;
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Prompt Used:\n---\n${prompt}\n---\n\n`,
);
}
const model = resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false, // useGemini3_1
false, // useGemini3_1FlashLite
false, // useCustomToolModel
this.config.getHasAccessToPreviewModel?.() ?? true,
this.config,
);
const response = await contentGenerator.generateContent(
{
model,
contents: [
{
role: 'user',
parts: [{ text: prompt }],
},
],
},
'simulator-prompt',
LlmRole.UTILITY_SIMULATOR,
);
let responseText = '';
let parsedJson: SimulatorResponse = {};
try {
let cleanJson = response.text || '';
const startIdx = cleanJson.indexOf('{');
const endIdx = cleanJson.lastIndexOf('}');
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
cleanJson = cleanJson.substring(startIdx, endIdx + 1);
} else {
cleanJson = cleanJson.replace(/^```json\s*|\s*```$/gm, '').trim();
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
parsedJson = JSON.parse(cleanJson) as SimulatorResponse;
responseText = parsedJson.action || '';
} catch (err) {
debugLogger.error('Failed to parse simulator response as JSON', err);
const text = (response.text || '').trim();
if (
text === '<WAIT>' ||
text === '<DONE>' ||
/^\d+\\r$/.test(text) ||
text === '\\r'
) {
responseText = text.replace(/^[`"']+|[`"']+$/g, '');
} else {
responseText = ''; // Prevent typing broken JSON string
}
}
const trimmedResponse = responseText.trim();
debugLogger.log(
`[SIMULATOR] Raw model response: ${JSON.stringify(response.text)}`,
);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Raw model response: ${JSON.stringify(response.text)}\n\n`,
);
}
debugLogger.log(
`[SIMULATOR] Processed response: ${JSON.stringify(responseText)}`,
);
if (trimmedResponse === '<DONE>') {
const msg = '[SIMULATOR] Terminating simulation: Task is completed.';
debugLogger.log(msg);
if (this.interactionsFile) {
fs.appendFileSync(this.interactionsFile, `[LOG] ${msg}\n\n`);
}
// eslint-disable-next-line no-console
console.log(`\n${msg}`);
this.stop();
process.exit(0);
}
if (trimmedResponse === '<WAIT>') {
debugLogger.log(
'[SIMULATOR] Skipping action (model decided to <WAIT>)',
);
this.actionHistory.push('<WAIT>');
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Action History updated with: "<WAIT>"\n\n`,
);
}
this.lastScreenContent = normalizedScreen;
return;
}
if (responseText) {
const keys = responseText
.replace(/\\n|\n/g, '\r')
.replace(/\\r/g, '\r');
debugLogger.log(
`[SIMULATOR] Sending to stdin: ${JSON.stringify(keys)}`,
);
this.actionHistory.push(keys);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Action History updated with: ${JSON.stringify(keys)}\n\n`,
);
}
if (
!parsedJson.used_knowledge &&
parsedJson.new_rule &&
this.editableKnowledgeFile
) {
const newKnowledge = `- ${parsedJson.new_rule}\n`;
this.knowledgeBase += newKnowledge;
try {
fs.appendFileSync(this.editableKnowledgeFile, newKnowledge);
debugLogger.log(
`[SIMULATOR] Saved new knowledge to ${this.editableKnowledgeFile}`,
);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Saved new knowledge to ${this.editableKnowledgeFile}\n\n`,
);
}
} catch (e) {
debugLogger.error(`Failed to append knowledge`, e);
}
}
for (const char of keys) {
if (char === '\r') {
// Wait a bit to ensure the previous character is rendered before submitting
await new Promise((resolve) => setTimeout(resolve, 50));
}
this.stdinBuffer.write(char);
// Small delay to ensure Ink processes each keypress event individually
// while preventing UI state collisions during long simulated inputs.
await new Promise((resolve) => setTimeout(resolve, 5));
}
this.lastScreenContent = normalizedScreen;
} else {
debugLogger.log('[SIMULATOR] Skipping (empty response)');
this.actionHistory.push('<EMPTY>');
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Action History updated with: "<EMPTY>"\n\n`,
);
}
this.lastScreenContent = normalizedScreen;
}
} catch (e: unknown) {
debugLogger.error('UserSimulator tick failed', e);
} finally {
this.isProcessing = false;
}
}
}
@@ -65,6 +65,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getGeminiMdFileCount: vi.fn(() => 0),
getDeferredCommand: vi.fn(() => undefined),
getFileSystemService: vi.fn(() => ({})),
getSimulateUser: vi.fn(() => false),
clientVersion: '1.0.0',
getModel: vi.fn().mockReturnValue('gemini-pro'),
getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'),
+6
View File
@@ -1407,7 +1407,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
const isMcpOrConfigReady = isConfigInitialized && isMcpReady;
debugLogger.log(
`[AppContainer] handleFinalSubmit: streamingState=${streamingState}, isIdle=${isIdle}, isSlash=${isSlash}`,
);
if ((isSlash && isConfigInitialized) || (isIdle && isMcpOrConfigReady)) {
debugLogger.log(
`[AppContainer] handleFinalSubmit: condition met, calling submitQuery`,
);
if (!isSlash) {
const permissions = await checkPermissions(submittedValue, config);
if (permissions.length > 0) {
@@ -868,24 +868,28 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
: undefined;
// Reserve space for at least 3 items if more selectionItems available.
const reservedListHeight = Math.min(selectionItems.length * 2, 6);
const questionHeightLimit =
listHeight && !isAlternateBuffer
? question.unconstrainedHeight
? Math.max(1, listHeight - selectionItems.length * 2)
: Math.max(1, listHeight - Math.max(DIALOG_PADDING, reservedListHeight))
: Math.min(
30,
Math.max(1, listHeight - Math.min(selectionItems.length, 5) * 2),
)
: undefined;
const maxItemsToShow =
listHeight && (!isAlternateBuffer || availableHeight !== undefined)
? Math.min(
selectionItems.length,
Math.max(
1,
Math.floor((listHeight - (questionHeightLimit ?? 0)) / 2),
),
)
: selectionItems.length;
let maxItemsToShow = selectionItems.length;
if (listHeight && (!isAlternateBuffer || availableHeight !== undefined)) {
if (selectionItems.length <= 5) {
maxItemsToShow = selectionItems.length;
} else {
maxItemsToShow = Math.min(
selectionItems.length,
Math.max(1, Math.floor((listHeight - (questionHeightLimit ?? 0)) / 2)),
);
}
}
return (
<Box flexDirection="column">
@@ -79,6 +79,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
useEffect(() => {
let ignore = false;
setState({ status: PlanStatus.Loading });
debugLogger.debug('usePlanContent loading plan:', planPath);
const load = async () => {
try {
@@ -125,6 +126,10 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
setState({ status: PlanStatus.Error, error: 'Plan file is empty.' });
return;
}
debugLogger.debug(
'usePlanContent loaded successfully, length:',
content.length,
);
setState({ status: PlanStatus.Loaded, content });
} catch (err: unknown) {
if (ignore) return;
@@ -375,6 +375,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const handleSubmitAndClear = useCallback(
(submittedValue: string) => {
debugLogger.log(`[InputPrompt] handleSubmitAndClear: \${submittedValue}`);
let processedValue = submittedValue;
if (buffer.pastedContent) {
processedValue = expandPastePlaceholders(
@@ -425,6 +426,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const handleSubmit = useCallback(
(submittedValue: string) => {
debugLogger.log(`[InputPrompt] handleSubmit: \${submittedValue}`);
const trimmedMessage = submittedValue.trim();
const isSlash = isSlashCommand(trimmedMessage);
@@ -649,6 +651,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const handleInput = useCallback(
(key: Key) => {
debugLogger.log(
`[UI INPUT] handleInput received key: ${JSON.stringify(key)}`,
);
// Determine if this keypress is a history navigation command
const isHistoryUp =
!shellModeActive &&
@@ -1214,9 +1219,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (keyMatchers[Command.SUBMIT](key)) {
debugLogger.log(
`[InputPrompt] Command.SUBMIT matched, buffer.text="${buffer.text}"`,
);
if (buffer.text.trim()) {
// Check if a paste operation occurred recently to prevent accidental auto-submission
if (recentUnsafePasteTime !== null) {
debugLogger.log(
`[InputPrompt] Command.SUBMIT ignored due to recentUnsafePasteTime`,
);
// Paste occurred recently in a terminal where we don't trust pastes
// to be reported correctly so assume this paste was really a
// newline that was part of the paste.
@@ -1234,8 +1245,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.backspace();
buffer.newline();
} else {
debugLogger.log(
`[InputPrompt] Calling handleSubmit from handleInput`,
);
handleSubmit(buffer.text);
}
} else {
debugLogger.log(
`[InputPrompt] Command.SUBMIT ignored because buffer is empty`,
);
}
return true;
}
@@ -14,12 +14,24 @@ Spinner Working...
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 1`] = `
"
Spinner Working...
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
"
`;
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = `
"
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
"
`;
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
"
Spinner Working...
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
"
`;
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = `
"
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
"
`;
@@ -8,6 +8,7 @@ import type React from 'react';
import { useCallback, useRef } from 'react';
import { Text, Box, type DOMElement } from 'ink';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { debugLogger } from '@google/gemini-cli-core';
import chalk from 'chalk';
import { theme } from '../../semantic-colors.js';
import { expandPastePlaceholders, type TextBuffer } from './text-buffer.js';
@@ -56,6 +57,9 @@ export function TextInput({
const handleKeyPress = useCallback(
(key: Key) => {
debugLogger.log(
`[TEXT INPUT] handleKeyPress received key: ${JSON.stringify(key)}`,
);
if (key.name === 'escape' && onCancel) {
onCancel();
return true;
@@ -873,8 +873,15 @@ export function KeypressProvider({
process.stdin.setEncoding('utf8'); // Make data events emit strings
debugLogger.log(
`[DEBUG] KeypressProvider simulateUser: ${config?.getSimulateUser()}`,
);
let processor = nonKeyboardEventFilter(broadcast);
if (!terminalCapabilityManager.isKittyProtocolEnabled()) {
if (
!terminalCapabilityManager.isKittyProtocolEnabled() &&
!config?.getSimulateUser()
) {
processor = bufferFastReturn(processor);
}
processor = bufferBackslashEnter(processor);
+4 -2
View File
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-assignment */
import React, { useMemo } from 'react';
import {
Text,
@@ -210,7 +212,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
// Use the TIGHTEST widths that fit the wrapped content + padding
const adjustedWidths = actualColumnWidths.map(
(w) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
w + COLUMN_PADDING,
);
@@ -263,7 +265,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
isHeader = false,
): React.ReactNode => {
const renderedCells = cells.map((cell, index) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const width = adjustedWidths[index] || 0;
return renderCell(cell, width, isHeader);
});
+21
View File
@@ -626,6 +626,7 @@ export interface ConfigParameters {
bugCommand?: BugCommandSettings;
model: string;
disableLoopDetection?: boolean;
disableStreaming?: boolean;
maxSessionTurns?: number;
acpMode?: boolean;
listSessions?: boolean;
@@ -728,6 +729,8 @@ export interface ConfigParameters {
billing?: {
overageStrategy?: OverageStrategy;
};
simulateUser?: boolean;
knowledgeSource?: string;
}
export class Config implements McpContext, AgentLoopContext {
@@ -958,6 +961,9 @@ export class Config implements McpContext, AgentLoopContext {
private lastModeSwitchTime: number = performance.now();
readonly injectionService: InjectionService;
private approvedPlanPath: string | undefined;
private readonly simulateUser: boolean;
private readonly knowledgeSource?: string;
private readonly disableStreaming: boolean;
constructor(params: ConfigParameters) {
this._sessionId = params.sessionId;
@@ -1278,6 +1284,9 @@ export class Config implements McpContext, AgentLoopContext {
this.fileExclusions = new FileExclusions(this);
this.eventEmitter = params.eventEmitter;
this.enableConseca = params.enableConseca ?? false;
this.simulateUser = params.simulateUser ?? false;
this.knowledgeSource = params.knowledgeSource;
this.disableStreaming = params.disableStreaming ?? false;
// Initialize Safety Infrastructure
const contextBuilder = new ContextBuilder(this);
@@ -2870,6 +2879,18 @@ export class Config implements McpContext, AgentLoopContext {
return this.usageStatisticsEnabled;
}
getSimulateUser(): boolean {
return this.simulateUser;
}
getDisableStreaming(): boolean {
return this.disableStreaming;
}
getKnowledgeSource(): string | undefined {
return this.knowledgeSource;
}
getAcpMode(): boolean {
return this.acpMode;
}
@@ -294,7 +294,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
family: 'gemini-3',
isPreview: true,
isVisible: true,
features: { thinking: false, multimodalToolUse: true },
features: { thinking: true, multimodalToolUse: true },
},
'gemini-2.5-pro': {
tier: 'pro',
+17
View File
@@ -656,6 +656,23 @@ export class GeminiChat {
lastConfig = config;
lastContentsToUse = contentsToUse;
if (this.context.config.getDisableStreaming()) {
const response = await this.context.config
.getContentGenerator()
.generateContent(
{
model: modelToUse,
contents: contentsToUse,
config,
},
prompt_id,
role,
);
return (async function* () {
yield response;
})();
}
return this.context.config.getContentGenerator().generateContentStream(
{
model: modelToUse,
+1
View File
@@ -16,5 +16,6 @@ export enum LlmRole {
UTILITY_EDIT_CORRECTOR = 'utility_edit_corrector',
UTILITY_AUTOCOMPLETE = 'utility_autocomplete',
UTILITY_FAST_ACK_HELPER = 'utility_fast_ack_helper',
UTILITY_SIMULATOR = 'utility_simulator',
UTILITY_STATE_SNAPSHOT_PROCESSOR = 'utility_state_snapshot_processor',
}
+1 -1
View File
@@ -350,7 +350,7 @@ export async function isEmpty(filePath: string): Promise<boolean> {
*/
export async function isBinaryFile(filePath: string): Promise<boolean> {
try {
return await isBinaryFileCheck(filePath);
return Boolean(await isBinaryFileCheck(filePath));
} catch (error) {
debugLogger.warn(
`Failed to check if file is binary: ${filePath}`,
+1 -1
View File
@@ -129,7 +129,7 @@
"@types/vscode": "^1.99.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vscode/vsce": "^3.6.0",
"@vscode/vsce": "^3.7.1",
"esbuild": "^0.25.3",
"eslint": "^9.25.1",
"npm-run-all2": "^8.0.2",
+3
View File
@@ -48,6 +48,9 @@ if (packageName === 'core') {
const docsSource = join(process.cwd(), '..', '..', 'docs');
const docsTarget = join(process.cwd(), 'dist', 'docs');
if (existsSync(docsSource)) {
if (existsSync(docsTarget)) {
execSync(`rm -rf "${docsTarget}"`);
}
cpSync(docsSource, docsTarget, { recursive: true, dereference: true });
console.log('Copied documentation to dist/docs');
}
+83
View File
@@ -0,0 +1,83 @@
#!/bin/bash
# Running User Simulation in Docker with External Knowledge Source
#
# This script automates the process of building the sandbox image and running
# the User Simulator inside it, while mounting a local workspace for persistent
# artifacts, logs, and knowledge state.
set -e
# Default values
TASK_PROMPT=${1:-"make a snake game in python"}
WORKSPACE_DIR=${2:-"$(pwd)/simulator_workspace_$(date +%s)"}
KNOWLEDGE_FILE="$WORKSPACE_DIR/knowledge.md"
LOG_FILE="$WORKSPACE_DIR/debug_$(date +%s).log"
echo "========================================================"
echo "🚀 Setting up Simulator Docker Environment..."
echo "========================================================"
echo "Workspace: $WORKSPACE_DIR"
echo "Task: $TASK_PROMPT"
echo "--------------------------------------------------------"
# 1. Prepare Workspace
mkdir -p "$WORKSPACE_DIR"
chmod 777 "$WORKSPACE_DIR"
if [ ! -f "$KNOWLEDGE_FILE" ]; then
touch "$KNOWLEDGE_FILE"
chmod 777 "$KNOWLEDGE_FILE"
echo "[INFO] Created new knowledge file at $KNOWLEDGE_FILE"
else
echo "[INFO] Using existing knowledge file at $KNOWLEDGE_FILE"
fi
# Create a project-level settings.json to natively bypass trust and auth dialogs.
# The simulator's AI brain (content generator) is only initialized AFTER the CLI is authenticated.
# Pre-configuring these settings prevents a Catch-22 where the simulator is stuck on the auth page.
mkdir -p "$WORKSPACE_DIR/.gemini"
chmod 777 "$WORKSPACE_DIR/.gemini"
SETTINGS_FILE="$WORKSPACE_DIR/.gemini/settings.json"
echo '{
"security": {
"auth": {
"selectedType": "gemini-api-key"
},
"folderTrust": {
"enabled": false
}
}
}' > "$SETTINGS_FILE"
chmod 777 "$SETTINGS_FILE"
# 2. Build the Sandbox Image (ensuring latest code is used)
echo ""
echo "📦 Building Sandbox Image..."
echo "This ensures any recent code changes are included in the image."
GEMINI_SANDBOX=docker npm run build:sandbox -- -i gemini-cli-simulator:latest
# 3. Run the Simulation
echo ""
echo "🤖 Starting Simulation..."
echo "Logs will be written to: $LOG_FILE"
echo "Press Ctrl+C to terminate early."
echo ""
# Note: We run the container directly with --init so that Ctrl+C cleanly kills the process.
# We mount the workspace and specifically mount the generated settings.json as the
# container's global user settings. This natively bypasses the initial interactive dialogs.
docker run -it --rm --init \
-v "$WORKSPACE_DIR:/workspace" \
-v "$SETTINGS_FILE:/home/node/.gemini/settings.json" \
-w /workspace \
-e GEMINI_API_KEY="$GEMINI_API_KEY" \
-e GEMINI_DEBUG_LOG_FILE="/workspace/$(basename "$LOG_FILE")" \
gemini-cli-simulator:latest \
gemini --prompt-interactive "$TASK_PROMPT" \
--approval-mode plan \
--simulate-user \
--knowledge-source "/workspace/$(basename "$KNOWLEDGE_FILE")"
echo ""
echo "✅ Simulation completed."
echo "Check $WORKSPACE_DIR for generated artifacts, logs, and updated knowledge source."