Compare commits

...

34 Commits

Author SHA1 Message Date
Mahima Shanware 2a3080bad7 test(cli): fix simulator fake timer leak and align with 10-stall limit 2026-05-26 16:24:28 +00:00
Mahima Shanware d94a2804ce chore: strictly declare non-null local variables in UserSimulator.ts 2026-05-26 16:18:07 +00:00
Mahima Shanware cfd7783c30 chore: fix user simulator type warning 2026-05-26 16:16:49 +00:00
Mahima Shanware fdce2643ca chore: save formatted files 2026-05-26 16:13:48 +00:00
Mahima Shanware 6555622b81 feat(simulator): clean restoration of SIGWINCH refresh logic 2026-05-26 16:13:28 +00:00
Mahima Shanware 583d42b86f feat(simulator): disable all SIGWINCH refresh logic to prevent state drift 2026-05-26 16:13:28 +00:00
Mahima Shanware d472777ac0 feat(simulator): disable dynamic rule generation to ensure benchmark stability 2026-05-26 16:13:28 +00:00
Mahima Shanware a513fa5b06 feat(simulator): implement multi-stage patient refresh with SIGWINCH incremental backoff 2026-05-26 16:13:28 +00:00
Mahima Shanware dcf47f9207 chore: optimize SIGWINCH vision and recovery logic 2026-05-26 16:13:28 +00:00
Mahima Shanware 0c02dec524 feat: implement automatic SIGWINCH repaint in User Simulator to resolve TTY rendering lags 2026-05-26 16:13:27 +00:00
Mahima Shanware ba3f3cb52e feat: increase simulator stabilization delay to 2s to mitigate TTY rendering lag 2026-05-26 16:13:27 +00:00
Mahima Shanware 84bb13bd73 fix(simulator): refine stall recovery and state normalization 2026-05-26 16:13:27 +00:00
Mahima Shanware 251235a55b fix(simulator): improve PTY stabilization and stall recovery 2026-05-26 16:13:27 +00:00
Mahima Shanware 124a9a6d03 feat(simulator): implement episodic memory with background compression
- Add session_notes field to simulator JSON response schema.

- Capture and persist factual session notes in sessionMemory.

- Inject episodic memory into system prompt for off-screen context.

- Add background LLM process to compress memory when it exceeds 5 items.

- Add unit tests for note capture, prompt injection, and compression merge.
2026-05-26 16:13:27 +00:00
Mahima Shanware a2e862e8b4 fix(simulator): allow acting on static screens when blocked to handle UI lag 2026-05-26 16:13:26 +00:00
Mahima Shanware 88d9edf345 feat(cli): fix UserSimulator deadlock on static screens by tracking composite state 2026-05-26 16:13:26 +00:00
Mahima Shanware c6b1051174 feat(core): synchronize toolset in GeminiChat to fix race condition 2026-05-26 16:13:26 +00:00
Mahima Shanware 5a0014e9ae 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.
2026-05-26 16:13:26 +00:00
Mahima Shanware 447cab514a feat(cli): improve UserSimulator robustness and prompt sensitivity
- Prioritize interactive markers (prompts, confirmation dialogs) over background timers/spinners.

- Suppress background update checks and non-fatal UI warnings when simulateUser is enabled.

- Refine simulator system prompt for more aggressive prompt detection.

- Implement reliable key submission with stabilized terminal initialization and inter-character delays.

- Add unit tests for UserSimulator vision and input submission.
2026-05-26 16:13:26 +00:00
Your Name 0a5bfd86ec Modify UserSimulator prompt to prefer 'Allow for this session' for execution prompts 2026-05-26 16:13:26 +00:00
Hadi Minooei ac63417a59 feat: add option to disable streaming in CLI 2026-05-26 16:13:26 +00:00
Your Name 5bce1f78bd Fix regex gap in UserSimulator to handle 'Xm' timer format 2026-05-26 16:13:25 +00:00
Your Name 27ed3a4d07 Improve screen normalization in UserSimulator to prevent polling loops 2026-05-26 16:13:25 +00:00
Your Name 7cc657add2 Fix race condition in terminal rendering in UserSimulator 2026-05-26 16:13:25 +00:00
Your Name fc05f78e55 Add debug logging to submission flow in InputPrompt 2026-05-26 16:13:25 +00:00
Your Name 6d7620cb80 Add debug logging to handleFinalSubmit in AppContainer 2026-05-26 16:13:25 +00:00
Your Name bea622e5e9 Fix keypress handling when simulateUser is enabled and add debug logging 2026-05-26 16:13:25 +00:00
Your Name 7b39a05afb Add debug logging for keypresses in TextInput 2026-05-26 16:13:25 +00:00
Your Name c044bcd205 Add debug logging for input keys in InputPrompt 2026-05-26 16:13:24 +00:00
Hadi Minooei 78754b9349 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-05-26 16:13:24 +00:00
Mahima Shanware b9d3ede51d fix(core): support nested directory structure in Plan Mode write_file and edit 2026-05-26 16:13:15 +00:00
Mahima Shanware d43af03598 fix(core): map ProceedAlways to ProceedOnce in Plan Mode to prevent exiting plan mode 2026-05-26 16:12:40 +00:00
Your Name af332c614a Add debug logging to usePlanContent in ExitPlanModeDialog 2026-05-26 16:12:40 +00:00
Hadi Minooei 922c6af4df fix(build): handle file exists error in build_package 2026-05-26 16:12:40 +00:00
36 changed files with 2147 additions and 102 deletions
+1
View File
@@ -68,3 +68,4 @@ temp_agents/
# conductor extension and planning directories
conductor/
simulator_workspace_*
+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".
+409 -13
View File
@@ -449,7 +449,8 @@
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
"license": "(Apache-2.0 AND BSD-3-Clause)",
"peer": true
},
"node_modules/@bundled-es-modules/cookie": {
"version": "2.0.1",
@@ -1535,6 +1536,7 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -2242,6 +2244,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2422,6 +2425,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2471,6 +2475,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz",
"integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2821,6 +2826,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz",
"integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2855,6 +2861,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz",
"integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/resources": "2.7.1"
@@ -2910,6 +2917,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz",
"integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/resources": "2.7.1",
@@ -4162,6 +4170,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4435,6 +4444,7 @@
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/types": "8.58.2",
@@ -4818,17 +4828,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.1",
"resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.1.tgz",
"integrity": "sha512-MPn5p+DoudI+3GfJSpAZZraE1lgLv0LcwbH3+xy7RgEhty3UIkmUMUA+5jPTDaxXae00AnX5u77FxGM8FhfKKA==",
"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",
@@ -4845,13 +4855,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": {
@@ -5016,6 +5026,47 @@
"dev": true,
"license": "MIT"
},
"node_modules/@vscode/vsce/node_modules/glob": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/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://registry.npmjs.org/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",
@@ -5086,6 +5137,20 @@
"dev": true,
"license": "ISC"
},
"node_modules/@vscode/vsce/node_modules/yauzl": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.3.1.tgz",
"integrity": "sha512-RNPCUkiE/ZgO4w8i9U5yDQVHaFDdnzaFANElRvpJteCspvmv2VqrRb9lvS6odVD+jqI/zDsxAHJVsafpcheVQQ==",
"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",
@@ -5210,6 +5275,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5841,6 +5907,19 @@
"url": "https://bevry.me/fund"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/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",
@@ -5920,6 +5999,32 @@
"node": ">=8"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/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",
@@ -6902,6 +7007,23 @@
}
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/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",
@@ -7304,6 +7426,17 @@
"node": ">=0.10.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/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",
@@ -7321,7 +7454,8 @@
"version": "0.0.1581282",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
"integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7906,6 +8040,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8489,6 +8624,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/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",
@@ -8516,6 +8662,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -9044,6 +9191,14 @@
"node": ">= 0.8"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/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",
@@ -9301,6 +9456,14 @@
"node": ">= 14"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/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",
@@ -9785,6 +9948,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -9953,6 +10117,28 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/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",
@@ -10047,6 +10233,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz",
"integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-escapes": "^7.0.0",
"ansi-styles": "^6.2.3",
@@ -11197,6 +11384,19 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/keytar": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/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",
@@ -11936,6 +12136,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/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.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -12000,6 +12214,14 @@
"node": ">=10"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/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",
@@ -12202,6 +12424,14 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/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",
@@ -12227,6 +12457,28 @@
"node": ">= 0.4.0"
}
},
"node_modules/node-abi": {
"version": "3.92.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
"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://registry.npmjs.org/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",
@@ -13403,6 +13655,75 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/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://registry.npmjs.org/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://registry.npmjs.org/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://registry.npmjs.org/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",
@@ -13822,6 +14143,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13832,6 +14154,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -14842,6 +15165,55 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/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://registry.npmjs.org/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.36.0",
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.36.0.tgz",
@@ -15986,6 +16358,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16208,7 +16581,8 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16216,6 +16590,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16240,6 +16615,20 @@
"node": ">=0.6.11 <=0.7.0 || >=0.7.3"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/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",
@@ -16381,6 +16770,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16448,6 +16838,7 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -16867,6 +17258,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -17437,6 +17829,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17449,6 +17842,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -18102,6 +18496,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -18625,6 +19020,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -18767,7 +19163,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",
+35
View File
@@ -290,6 +290,22 @@ 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',
@@ -1008,6 +1024,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;
@@ -110,6 +112,8 @@ export interface CliArgs {
acceptRawOutputRisk: boolean | undefined;
skipTrust: boolean | undefined;
isCommand: boolean | undefined;
simulateUser: boolean | undefined;
knowledgeSource: string | undefined;
}
/**
@@ -463,6 +467,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',
@@ -494,6 +502,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
@@ -952,6 +978,7 @@ export async function loadCliConfig(
return new Config({
acpMode: isAcpMode,
clientName,
disableStreaming: argv.disableStreaming,
sessionId,
clientVersion: await getVersion(),
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
@@ -1001,6 +1028,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
@@ -567,6 +567,8 @@ describe('gemini.tsx main function kitty protocol', () => {
acceptRawOutputRisk: undefined,
isCommand: undefined,
skipTrust: undefined,
simulateUser: undefined,
knowledgeSource: undefined,
});
await act(async () => {
@@ -627,6 +629,8 @@ describe('gemini.tsx main function kitty protocol', () => {
acceptRawOutputRisk: undefined,
isCommand: undefined,
skipTrust: undefined,
simulateUser: undefined,
knowledgeSource: undefined,
});
await act(async () => {
+59 -19
View File
@@ -14,6 +14,14 @@ import {
removeCleanup,
setupTtyCheck,
} from './utils/cleanup.js';
import { UserSimulator } from './services/UserSimulator.js';
import { PassThrough } from 'node:stream';
interface RenderMetrics {
renderTime: number;
output: string;
staticOutput?: string;
}
import {
type StartupWarning,
type Config,
@@ -86,11 +94,14 @@ export async function startInteractiveUI(
const version = await getVersion();
setWindowTitle(basename(workspaceRoot), settings);
const simulateUser = config.getSimulateUser();
const consolePatcher = new ConsolePatcher({
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
},
debugMode: config.getDebugMode(),
interactive: !simulateUser,
});
consolePatcher.patch();
@@ -135,6 +146,10 @@ export async function startInteractiveUI(
// Wait a moment for shpool to stabilize terminal size and state.
await new Promise((resolve) => setTimeout(resolve, 100));
}
const simulatedStdin = new PassThrough({ encoding: 'utf8' });
let lastFrame: string | undefined;
const staticHistory: string[] = [];
const instance = render(
process.env['DEBUG'] ? (
<React.StrictMode>
@@ -146,12 +161,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, Math.round(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, Math.round(metrics.renderTime));
}
profiler.reportFrameRendered();
},
@@ -177,25 +200,42 @@ export async function startInteractiveUI(
registerCleanup(cleanupLineWrapping);
}
checkForUpdates(settings)
.then((info) => {
handleAutoUpdate(
info,
settings,
config.getProjectRoot(),
config.getSandboxEnabled(),
);
})
.catch((err) => {
// Silently ignore update check errors.
if (config.getDebugMode()) {
debugLogger.warn('Update check failed:', err);
}
});
if (!simulateUser) {
checkForUpdates(settings)
.then((info) => {
handleAutoUpdate(
info,
settings,
config.getProjectRoot(),
config.getSandboxEnabled(),
);
})
.catch((err) => {
// Silently ignore update check errors.
if (config.getDebugMode()) {
debugLogger.warn('Update check failed:', err);
}
});
}
const cleanupUnmount = () => instance.unmount();
registerCleanup(cleanupUnmount);
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 cleanupTtyCheck = setupTtyCheck();
registerCleanup(cleanupTtyCheck);
@@ -0,0 +1,308 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import { UserSimulator } from './UserSimulator.js';
import { Writable } from 'node:stream';
import {
type Config,
MessageBusType,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
describe('UserSimulator', () => {
let mockConfig: Config;
let mockGetScreen: Mock<() => string | undefined>;
let mockStdinBuffer: Writable;
let mockContentGenerator: {
generateContent: Mock;
};
let mockMessageBus: {
subscribe: Mock;
unsubscribe: Mock;
};
beforeEach(() => {
mockContentGenerator = {
generateContent: vi
.fn()
.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();
mockStdinBuffer = new Writable({
write(chunk, encoding, callback) {
callback();
},
});
vi.spyOn(mockStdinBuffer, 'write');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should include interactive prompts in its vision even when timers are present', async () => {
const simulator = new UserSimulator(
mockConfig,
mockGetScreen,
mockStdinBuffer,
);
// Mock a screen with a timer and a confirmation prompt
mockGetScreen.mockReturnValue(
'Thinking... (0s)\n\nAction Required: Allow pip execution? [Y/n]',
);
// Start simulator to initialize isRunning and subscribers, but clear interval immediately
simulator.start();
if (simulator['timer']) clearInterval(simulator['timer']);
// Directly run the private tick method synchronously
await simulator['tick']();
expect(mockContentGenerator.generateContent).toHaveBeenCalled();
const lastCall = mockContentGenerator.generateContent.mock.calls[0];
const prompt = lastCall[0].contents[0].parts[0].text;
expect(prompt).toContain(
'STATE 2: The agent is waiting for you to authorize a tool',
);
expect(prompt).toContain('[Y/n]');
expect(prompt).toContain('RULE 1: If there is a clear confirmation prompt');
simulator.stop();
});
it('should not wait if a prompt is visible even if a spinner is present', async () => {
const simulator = new UserSimulator(
mockConfig,
mockGetScreen,
mockStdinBuffer,
);
// Mock a screen with a spinner and a prompt
mockGetScreen.mockReturnValue('⠋ Working...\n> Type your message');
simulator.start();
if (simulator['timer']) clearInterval(simulator['timer']);
await simulator['tick']();
expect(mockContentGenerator.generateContent).toHaveBeenCalled();
const lastCall = mockContentGenerator.generateContent.mock.calls[0];
const prompt = lastCall[0].contents[0].parts[0].text;
expect(prompt).toContain(
'Only <WAIT> (Rule 1 fallback) if the agent is truly mid-process',
);
simulator.stop();
});
it('should submit keys with reliable delays', async () => {
const simulator = new UserSimulator(
mockConfig,
mockGetScreen,
mockStdinBuffer,
);
mockGetScreen.mockReturnValue('> Prompt');
mockContentGenerator.generateContent.mockResolvedValue({
text: JSON.stringify({ action: 'abc' }),
});
simulator.start();
if (simulator['timer']) clearInterval(simulator['timer']);
await simulator['tick']();
expect(mockStdinBuffer.write).toHaveBeenCalledWith('a');
expect(mockStdinBuffer.write).toHaveBeenCalledWith('b');
expect(mockStdinBuffer.write).toHaveBeenCalledWith('c');
simulator.stop();
});
it('should inject internal tool state into the prompt', async () => {
const simulator = new UserSimulator(
mockConfig,
mockGetScreen,
mockStdinBuffer,
);
mockGetScreen.mockReturnValue('Responding...');
simulator.start();
if (simulator['timer']) clearInterval(simulator['timer']);
// 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' },
},
],
});
await simulator['tick']();
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();
});
it('should terminate if terminal state does not change after 10 consecutive inputs', async () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
return undefined as never;
});
const simulator = new UserSimulator(
mockConfig,
mockGetScreen,
mockStdinBuffer,
);
mockGetScreen.mockReturnValue('Static Screen');
mockContentGenerator.generateContent.mockResolvedValue({
text: JSON.stringify({ action: 'y\r' }),
});
simulator.start();
if (simulator['timer']) clearInterval(simulator['timer']);
// Run 10 ticks manually. All of them fall through to generateContent.
for (let i = 0; i < 10; i++) {
await simulator['tick']();
}
expect(mockContentGenerator.generateContent).toHaveBeenCalledTimes(10);
// Run the 11th tick, which should trigger stall termination
await simulator['tick']();
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
simulator.stop();
});
it('should capture session notes and inject them into subsequent prompts', async () => {
const simulator = new UserSimulator(
mockConfig,
mockGetScreen,
mockStdinBuffer,
);
mockGetScreen.mockReturnValue('> Prompt 1');
mockContentGenerator.generateContent.mockResolvedValueOnce({
text: JSON.stringify({
action: 'ls\r',
session_notes: 'I listed the directory contents.',
}),
});
simulator.start();
if (simulator['timer']) clearInterval(simulator['timer']);
// First tick: captures note
await simulator['tick']();
expect(mockContentGenerator.generateContent).toHaveBeenCalledTimes(1);
// Second tick: different screen
mockGetScreen.mockReturnValue('> Prompt 2');
mockContentGenerator.generateContent.mockResolvedValueOnce({
text: JSON.stringify({ action: 'pwd\r' }),
});
await simulator['tick']();
expect(mockContentGenerator.generateContent).toHaveBeenCalledTimes(2);
const secondCall = mockContentGenerator.generateContent.mock.calls[1];
const prompt = secondCall[0].contents[0].parts[0].text;
expect(prompt).toContain(
"Your Session Memory (Key facts you've recorded):",
);
expect(prompt).toContain('1. I listed the directory contents.');
simulator.stop();
});
it('should trigger background compression when memory exceeds threshold and merge correctly', async () => {
const simulator = new UserSimulator(
mockConfig,
mockGetScreen,
mockStdinBuffer,
);
simulator.start();
if (simulator['timer']) clearInterval(simulator['timer']);
for (let i = 0; i < 5; i++) {
mockGetScreen.mockReturnValue(`> Prompt ${i}`);
mockContentGenerator.generateContent.mockResolvedValueOnce({
text: JSON.stringify({
action: 'wait\r',
session_notes: `Note ${i}`,
}),
});
await simulator['tick']();
}
expect(mockContentGenerator.generateContent).toHaveBeenCalledTimes(5);
// Resolve the compression call
mockContentGenerator.generateContent.mockImplementation(async (req, id) => {
if (id === 'simulator-compression') {
return { text: 'Compressed Summary' };
}
return { text: JSON.stringify({ action: 'y\r' }) };
});
// Wait for the background task to complete using Vitest waitFor
await vi.waitFor(() => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
const memory = (simulator as any).sessionMemory as string[];
return memory.length > 0 && memory[0] === 'Compressed Summary';
});
// Trigger one more tick to see if the compressed memory is used
mockGetScreen.mockReturnValue('> Final Prompt');
await simulator['tick']();
const finalCall = mockContentGenerator.generateContent.mock.calls.find(
(call) =>
call[0].contents[0].parts[0].text.includes('> Final Prompt') &&
call[1] === 'simulator-prompt',
);
expect(finalCall).toBeDefined();
if (finalCall) {
const finalPrompt = finalCall[0].contents[0].parts[0].text;
expect(finalPrompt).toContain('1. Compressed Summary');
}
simulator.stop();
});
});
+554
View File
@@ -0,0 +1,554 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
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';
import * as path from 'node:path';
interface SimulatorResponse {
action?: string;
thought?: string;
used_knowledge?: boolean;
new_rule?: string;
session_notes?: string;
}
export class UserSimulator {
private isRunning = false;
private timer: NodeJS.Timeout | null = null;
private lastStateKey = '';
private isProcessing = false;
private isCompressingMemory = false;
private consecutiveStallCount = 0;
private staleCycleCount = 0;
private interactionsFile: string | null = null;
private knowledgeBase = '';
private editableKnowledgeFile: string | null = null;
private actionHistory: string[] = [];
private sessionMemory: string[] = [];
private pendingToolCalls: ToolCall[] = [];
private messageBusHandler: ((msg: ToolCallsUpdateMessage) => void) | null =
null;
constructor(
private readonly config: Config,
private readonly getScreen: () => string | undefined,
private readonly stdinBuffer: Writable,
) {}
start() {
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)) {
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;
}
if (this.messageBusHandler) {
this.config
.getMessageBus()
.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, this.messageBusHandler);
this.messageBusHandler = 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;
// Patient refresh cycle: Attempt up to 3 SIGWINCH refreshes with increasing delays if screen is blank
let screen = this.getScreen();
let strippedScreen = '';
let normalizedScreen = '';
const refreshDelays = [1500, 3000, 5000];
for (let attempt = 0; attempt < refreshDelays.length; attempt++) {
if (!screen) break;
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');
normalizedScreen = strippedScreen
.replace(/[\u2800-\u28FF]/g, '') // Braille patterns
.replace(/[|/-\\]/g, '') // Spinners
.replace(/\b\d+(\.\d+)?s\b/g, '') // Timers (seconds)
.replace(/\b\d+m(\s+\d+s)?\b/g, '') // Timers (minutes)
.replace(/\b\d+%\b/g, '') // Percentages
.replace(/\b\d+\/\d+\b/g, '') // Progress ratios (e.g. 1/10)
.replace(/\(\s*\)/g, '')
.trim();
// If screen is not blank, or we are not blocked, proceed immediately
if (normalizedScreen.length > 0 || this.pendingToolCalls.length === 0) {
break;
}
// Screen is blank and we are blocked: Try a patient refresh
debugLogger.log(
`[SIMULATOR] Screen blank and BLOCKED. Attempting SIGWINCH refresh ${attempt + 1}/${refreshDelays.length} with ${refreshDelays[attempt]}ms delay.`,
);
try {
process.kill(0, 'SIGWINCH');
} catch {
process.kill(process.pid, 'SIGWINCH');
}
await new Promise((resolve) =>
setTimeout(resolve, refreshDelays[attempt]),
);
screen = this.getScreen();
}
if (!screen) return;
// Create a composite key representing the full state (Vision + Internal State)
const pendingIds = this.pendingToolCalls
.map((tc) => tc.request.callId)
.join(',');
const currentStateKey = `${normalizedScreen}::${pendingIds}`;
if (currentStateKey === this.lastStateKey) {
const lastAction = this.actionHistory[this.actionHistory.length - 1];
if (lastAction && lastAction !== '<WAIT>') {
this.consecutiveStallCount++;
// Increased limit to 10 for high-load environments.
if (this.consecutiveStallCount >= 10) {
const errorMsg = `[SIMULATOR] CRITICAL STALL DETECTED: Terminal state has not changed after ${this.consecutiveStallCount} consecutive inputs. Terminating to prevent loop.`;
debugLogger.error(errorMsg);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[ERROR] ${errorMsg}\n\n`,
);
}
// eslint-disable-next-line no-console
console.error(`\n${errorMsg}`);
this.stop();
process.exit(1);
}
// RECOVERY: If screen is blank and we are stalled, try a terminal refresh.
if (
normalizedScreen.length === 0 &&
this.pendingToolCalls.length > 0
) {
debugLogger.log(
'[SIMULATOR] Screen is blank but system is BLOCKED. Sending SIGWINCH refresh.',
);
try {
process.kill(0, 'SIGWINCH');
} catch {
process.kill(process.pid, 'SIGWINCH');
}
return;
}
} else {
// If it was a <WAIT> action or no action yet, we still want the 10s fallback for internal state sync
if (this.pendingToolCalls.length > 0) {
this.staleCycleCount++;
if (this.staleCycleCount % 10 !== 0) {
return;
}
} else {
return;
}
}
} else {
this.consecutiveStallCount = 0;
this.staleCycleCount = 0;
}
this.lastStateKey = currentStateKey;
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 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 sessionInstruction =
this.sessionMemory.length > 0
? `\nYour Session Memory (Key facts you've recorded):
${this.sessionMemory.map((m, i) => `${i + 1}. ${m}`).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, executing a tool, or showing a progress message). It is actively working and NOT waiting for text input or user approval.
- 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, "[Y/n]").
- 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. This state takes precedence even if timers or background messages are visible.
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 a clear confirmation prompt (e.g. "[Y/n]", "1) Allow Once") or an input cursor (">"), YOU MUST RESPOND (State 2 or 3). Detect these states aggressively. Only <WAIT> (Rule 1 fallback) if the agent is truly mid-process with no interactive markers visible.
- 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: Use the "session_notes" field to record important facts that are scrolling off the screen (e.g., test results, proposed plans, file names, errors). Keep notes extremely brief. DO NOT record transient states like "Agent is thinking". This memory helps you maintain context across the session.
- RULE 5: 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>>",
"session_notes": "<Brief factual note to remember for future turns, if applicable>",
"used_knowledge": <true if you used the User Knowledge Base below to answer this prompt, false otherwise>
}
${goalInstruction}${knowledgeInstruction}${sessionInstruction}${historyInstruction}${pendingToolInstruction}
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, // 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 || '';
if (parsedJson.session_notes) {
this.sessionMemory.push(parsedJson.session_notes);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Recorded session note: ${JSON.stringify(parsedJson.session_notes)}\n\n`,
);
}
}
} 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`,
);
}
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 (false) /* Disabled dynamic knowledge generation for evaluation stability */ {
const newKnowledge = `- ${parsedJson.new_rule}\n`;
this.knowledgeBase += newKnowledge;
const file = this.editableKnowledgeFile;
const logFile = this.interactionsFile;
if (file !== null) {
try {
fs.appendFileSync(file!, newKnowledge);
debugLogger.log(
`[SIMULATOR] Saved new knowledge to ${file}`,
);
if (logFile !== null) {
fs.appendFileSync(
logFile!,
`[LOG] [SIMULATOR] Saved new knowledge to ${file}\n\n`,
);
}
} catch (e) {
debugLogger.error(`Failed to append knowledge`, e);
}
}
}
// Wait a bit to ensure the terminal is ready for input
await new Promise((resolve) => setTimeout(resolve, 100));
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, 10));
}
// Wait a bit to ensure Ink has processed the full input
await new Promise((resolve) => setTimeout(resolve, 100));
} 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`,
);
}
}
if (this.sessionMemory.length >= 5 && !this.isCompressingMemory) {
// Trigger background compression (do not await)
this.compressMemory().catch((err) => {
debugLogger.error('Failed to compress simulator memory', err);
});
}
} catch (e: unknown) {
debugLogger.error('UserSimulator tick failed', e);
} finally {
this.isProcessing = false;
}
}
private async compressMemory() {
this.isCompressingMemory = true;
try {
const contentGenerator = this.config.getContentGenerator();
if (!contentGenerator) return;
const memoryToCompress = [...this.sessionMemory];
const prompt = `Summarize the following chronological session notes into a single, concise list of key facts, preserving specific technical details like file paths, proposed plans, and test results. Drop transient or obsolete observations.
Notes:
${memoryToCompress.map((m, i) => `${i + 1}. ${m}`).join('\n')}`;
const model = resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false, // useGemini3_1
false, // useCustomToolModel
this.config.getHasAccessToPreviewModel?.() ?? true,
this.config,
);
const response = await contentGenerator.generateContent(
{
model,
contents: [
{
role: 'user',
parts: [{ text: prompt }],
},
],
},
'simulator-compression',
LlmRole.UTILITY_SIMULATOR,
);
const summary = response.text?.trim();
if (summary) {
debugLogger.log(`[SIMULATOR] Memory compressed. Summary: ${summary}`);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Memory compressed. Summary: ${summary}\n\n`,
);
}
// Replace the older items with the new summary string, while preserving any new notes
// that arrived while the compression was running.
const newNotes = this.sessionMemory.slice(memoryToCompress.length);
this.sessionMemory = [summary, ...newNotes];
}
} finally {
this.isCompressingMemory = 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,10 +1407,16 @@ 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) ||
(!isCompressing && 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 {
@@ -126,6 +127,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;
@@ -408,6 +408,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(
@@ -460,6 +461,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);
@@ -686,6 +688,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
(key: Key) => {
if (handleVoiceInput(key)) return true;
debugLogger.log(
`[UI INPUT] handleInput received key: ${JSON.stringify(key)}`,
);
// Determine if this keypress is a history navigation command
const isHistoryUp =
!shellModeActive &&
@@ -1256,9 +1261,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.
@@ -1276,8 +1287,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;
}
@@ -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 (
@@ -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;
@@ -862,8 +862,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);
+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;
}
}
+21
View File
@@ -639,6 +639,7 @@ export interface ConfigParameters {
bugCommand?: BugCommandSettings;
model: string;
disableLoopDetection?: boolean;
disableStreaming?: boolean;
maxSessionTurns?: number;
acpMode?: boolean;
listSessions?: boolean;
@@ -744,6 +745,8 @@ export interface ConfigParameters {
};
vertexAiRouting?: VertexAiRoutingConfig;
logRagSnippets?: boolean;
simulateUser?: boolean;
knowledgeSource?: string;
}
export class Config implements McpContext, AgentLoopContext {
@@ -982,6 +985,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;
@@ -1310,6 +1316,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);
@@ -3023,6 +3032,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;
}
@@ -344,7 +344,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',
+20
View File
@@ -841,6 +841,9 @@ export class GeminiChat {
if (this.onModelChanged) {
this.tools = await this.onModelChanged(modelToUse);
// CRITICAL: Update the request config with the fresh tools
// to ensure mode-switches (like exit_plan_mode) are reflected immediately.
config.tools = this.tools;
}
// Track final request parameters for AfterModel hooks
@@ -850,6 +853,23 @@ export class GeminiChat {
const finalContents = stripToolCallIdPrefixes(contentsToUse);
if (this.context.config.getDisableStreaming()) {
const response = await this.context.config
.getContentGenerator()
.generateContent(
{
model: modelToUse,
contents: finalContents,
config,
},
prompt_id,
role,
);
return (async function* () {
yield response;
})();
}
return this.context.config.getContentGenerator().generateContentStream(
{
model: modelToUse,
@@ -0,0 +1,127 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GeminiChat } from './geminiChat.js';
import type { Config } from '../config/config.js';
import type { ContentGenerator } from './contentGenerator.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import { LlmRole } from '../telemetry/types.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import type { Tool } from '@google/genai';
import type { ToolRegistry } from '../tools/tool-registry.js';
// Mock retryWithBackoff
vi.mock('../utils/retry.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/retry.js')>();
return {
...actual,
retryWithBackoff: vi.fn().mockImplementation(async (apiCall) => apiCall()),
};
});
describe('GeminiChat Tool Synchronization', () => {
let mockContentGenerator: ContentGenerator;
let mockConfig: Config;
beforeEach(() => {
mockContentGenerator = {
generateContent: vi.fn().mockResolvedValue({
candidates: [
{
content: { parts: [{ text: 'response' }] },
finishReason: 'STOP',
},
],
}),
generateContentStream: vi.fn(),
} as unknown as ContentGenerator;
mockConfig = {
getDisableStreaming: vi.fn().mockReturnValue(true),
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getGemini31Launched: vi.fn().mockResolvedValue(false),
getGemini31FlashLiteLaunched: vi.fn().mockResolvedValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getMaxAttempts: vi.fn().mockReturnValue(1),
getRetryFetchErrors: vi.fn().mockReturnValue(false),
getHookSystem: vi.fn().mockReturnValue(undefined),
isInteractive: vi.fn().mockReturnValue(true),
getExperiments: vi.fn().mockReturnValue(undefined),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ model: 'gemini-pro' }),
getProjectRoot: vi.fn().mockReturnValue('/test/project/root'),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(true),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
getDebugMode: vi.fn().mockReturnValue(false),
getValidationHandler: vi.fn().mockReturnValue(undefined),
getModelAvailabilityService: vi.fn().mockReturnValue({
selectFirstAvailable: vi.fn().mockImplementation((models) => ({
model: models[0],
config: {},
})),
markHealthy: vi.fn(),
}),
modelConfigService: {
getResolvedConfig: vi.fn().mockReturnValue({
model: 'gemini-pro',
generateContentConfig: {},
}),
},
} as unknown as Config;
});
it('should update config.tools when this.tools is updated via onModelChanged', async () => {
const initialTools = [{ functionDeclarations: [{ name: 'tool1' }] }];
const updatedTools = [{ functionDeclarations: [{ name: 'tool2' }] }];
const onModelChanged = vi.fn().mockResolvedValue(updatedTools);
const chat = new GeminiChat(
{
config: mockConfig,
toolRegistry: {
getMessageBus: () => createMockMessageBus(),
} as unknown as ToolRegistry,
} as unknown as AgentLoopContext,
'system instruction',
initialTools as unknown as Tool[],
[], // history
undefined, // resumedSessionData
onModelChanged,
);
const stream = await chat.sendMessageStream(
{ model: 'gemini-pro' },
[{ text: 'user prompt' }],
'prompt-id',
new AbortController().signal,
LlmRole.UTILITY_TOOL,
);
for await (const _ of stream) {
// consume stream
}
// Verify onModelChanged was called
expect(onModelChanged).toHaveBeenCalled();
// Verify generateContent was called with updated tools
expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
tools: updatedTools,
}),
}),
expect.any(String),
LlmRole.UTILITY_TOOL,
);
});
});
@@ -813,6 +813,33 @@ describe('policy.ts', () => {
}),
);
});
it('should map ProceedAlways to ProceedOnce in Plan Mode', async () => {
const mockConfig = {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.PLAN),
setApprovalMode: vi.fn(),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
(mockConfig as unknown as { messageBus: MessageBus }).messageBus =
mockMessageBus;
const tool = { name: 'replace' } as AnyDeclarativeTool;
await updatePolicy(
tool,
ToolConfirmationOutcome.ProceedAlways,
undefined,
mockConfig,
mockMessageBus,
);
expect(mockConfig.setApprovalMode).not.toHaveBeenCalled();
expect(mockMessageBus.publish).not.toHaveBeenCalled();
});
});
describe('getPolicyDenialError', () => {
+8
View File
@@ -121,6 +121,14 @@ export async function updatePolicy(
): Promise<void> {
const currentMode = context.config.getApprovalMode();
// If in Plan Mode, map 'Proceed Always' (Allow for this session) to 'Proceed Once' (Allow once)
// to prevent transitioning to AUTO_EDIT mode and updating policy.
if (
currentMode === ApprovalMode.PLAN &&
outcome === ToolConfirmationOutcome.ProceedAlways
) {
outcome = ToolConfirmationOutcome.ProceedOnce;
}
// Mode Transitions (AUTO_EDIT)
if (isAutoEditTransition(tool, outcome)) {
context.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
+1
View File
@@ -17,4 +17,5 @@ export enum LlmRole {
UTILITY_AUTOCOMPLETE = 'utility_autocomplete',
UTILITY_FAST_ACK_HELPER = 'utility_fast_ack_helper',
UTILITY_STATE_SNAPSHOT_PROCESSOR = 'utility_state_snapshot_processor',
UTILITY_SIMULATOR = 'utility_simulator',
}
+80 -2
View File
@@ -1371,6 +1371,14 @@ function doIt() {
});
describe('plan mode', () => {
beforeEach(() => {
vi.mocked(mockConfig.isPlanMode).mockReturnValue(true);
});
afterEach(() => {
vi.mocked(mockConfig.isPlanMode).mockReturnValue(false);
});
it('should allow edits to plans directory when isPlanMode is true', async () => {
const mockProjectTempDir = path.join(tempDir, 'project');
fs.mkdirSync(mockProjectTempDir);
@@ -1380,8 +1388,6 @@ function doIt() {
const plansDir = path.join(mockProjectTempDir, 'plans');
fs.mkdirSync(plansDir);
vi.mocked(mockConfig.isPlanMode).mockReturnValue(true);
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
const filePath = 'test-file.txt';
@@ -1408,5 +1414,77 @@ function doIt() {
fs.rmSync(plansDir, { recursive: true, force: true });
});
it('should preserve nested directory structure within the plans directory in Plan Mode', async () => {
const mockProjectTempDir = path.join(tempDir, 'project');
fs.mkdirSync(mockProjectTempDir);
vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue(
mockProjectTempDir,
);
const plansDir = path.join(mockProjectTempDir, 'plans');
fs.mkdirSync(plansDir);
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
const nestedDir = path.join(plansDir, 'tracks', 'fibsqrt_20260519');
fs.mkdirSync(nestedDir, { recursive: true });
const planFilePath = path.join(nestedDir, 'spec.md');
const initialContent = 'some initial content';
fs.writeFileSync(planFilePath, initialContent, 'utf8');
const params: EditToolParams = {
file_path: 'tracks/fibsqrt_20260519/spec.md',
instruction: 'Replace initial with new',
old_string: 'initial',
new_string: 'new',
};
const invocation = tool.build(params);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
expect(result.llmContent).toMatch(/Successfully modified file/);
expect(fs.readFileSync(planFilePath, 'utf8')).toBe('some new content');
fs.rmSync(plansDir, { recursive: true, force: true });
});
it('should strip the leading plansDir folder name segment if present in path', async () => {
const mockProjectTempDir = path.join(tempDir, 'project');
fs.mkdirSync(mockProjectTempDir);
vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue(
mockProjectTempDir,
);
const plansDir = path.join(mockProjectTempDir, 'plans');
fs.mkdirSync(plansDir);
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
const nestedDir = path.join(plansDir, 'tracks', 'fibsqrt_20260519');
fs.mkdirSync(nestedDir, { recursive: true });
const planFilePath = path.join(nestedDir, 'spec.md');
const initialContent = 'some initial content';
fs.writeFileSync(planFilePath, initialContent, 'utf8');
const params: EditToolParams = {
file_path: 'plans/tracks/fibsqrt_20260519/spec.md',
instruction: 'Replace initial with new',
old_string: 'initial',
new_string: 'new',
};
const invocation = tool.build(params);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
expect(result.llmContent).toMatch(/Successfully modified file/);
expect(fs.readFileSync(planFilePath, 'utf8')).toBe('some new content');
fs.rmSync(plansDir, { recursive: true, force: true });
});
});
});
@@ -515,5 +515,28 @@ Ask the user for specific feedback on how to improve the plan.`,
});
expect(result).toBeNull();
});
it('should accept nested valid path within plans directory', () => {
const nestedDir = path.join(mockPlansDir, 'tracks', 'fibsqrt_20260519');
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(path.join(nestedDir, 'spec.md'), '# Content');
const result = tool.validateToolParams({
plan_filename: 'tracks/fibsqrt_20260519/spec.md',
});
expect(result).toBeNull();
});
it('should strip the leading plansDir folder name segment if present in path', () => {
const plansDirName = path.basename(mockPlansDir);
const nestedDir = path.join(mockPlansDir, 'tracks', 'fibsqrt_20260519');
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(path.join(nestedDir, 'spec.md'), '# Content');
const result = tool.validateToolParams({
plan_filename: `${plansDirName}/tracks/fibsqrt_20260519/spec.md`,
});
expect(result).toBeNull();
});
});
});
+3 -12
View File
@@ -226,28 +226,19 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
const exitMessage = getPlanModeExitMessage(newMode);
return {
llmContent: `${exitMessage}
The approved implementation plan is stored at: ${resolvedPlanPath}
Read and follow the plan strictly during implementation.`,
llmContent: `${exitMessage}\n\nThe approved implementation plan is stored at: ${resolvedPlanPath}\nRead and follow the plan strictly during implementation.`,
returnDisplay: `Plan approved: ${resolvedPlanPath}`,
};
} else {
const feedback = payload?.feedback?.trim();
if (feedback) {
return {
llmContent: `Plan rejected. User feedback: ${feedback}
The plan is stored at: ${resolvedPlanPath}
Revise the plan based on the feedback.`,
llmContent: `Plan rejected. User feedback: ${feedback}\n\nThe plan is stored at: ${resolvedPlanPath}\nRevise the plan based on the feedback.`,
returnDisplay: `Feedback: ${feedback}`,
};
} else {
return {
llmContent: `Plan rejected. No feedback provided.
The plan is stored at: ${resolvedPlanPath}
Ask the user for specific feedback on how to improve the plan.`,
llmContent: `Plan rejected. No feedback provided.\n\nThe plan is stored at: ${resolvedPlanPath}\nAsk the user for specific feedback on how to improve the plan.`,
returnDisplay: 'Rejected (no feedback)',
};
}
@@ -110,6 +110,7 @@ const mockConfigInternal = {
getActiveModel: () => 'test-model',
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
},
};
@@ -148,6 +149,7 @@ describe('WriteFileTool', () => {
const workspaceContext = new WorkspaceContext(rootDir, [plansDir]);
const mockStorage = {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
getPlansDir: vi.fn().mockReturnValue(plansDir),
};
mockConfig = {
@@ -1146,4 +1148,37 @@ describe('WriteFileTool', () => {
expect(fs.readFileSync(expectedWritePath, 'utf8')).toBe('nested content');
});
});
describe('Plan Mode path resolution', () => {
beforeEach(() => {
vi.mocked(mockConfigInternal.isPlanMode).mockReturnValue(true);
vi.mocked(mockConfigInternal.storage.getPlansDir).mockReturnValue(
plansDir,
);
});
afterEach(() => {
vi.mocked(mockConfigInternal.isPlanMode).mockReturnValue(false);
});
it('should preserve nested directory structure within the plans directory', () => {
const planFilePath = 'tracks/fibsqrt_20260519/spec.md';
const params = { file_path: planFilePath, content: '# Spec' };
const invocation = tool.build(params);
expect(
(invocation as unknown as { resolvedPath: string }).resolvedPath,
).toBe(path.resolve(plansDir, 'tracks/fibsqrt_20260519/spec.md'));
});
it('should strip the leading plansDir folder name segment if present in path', () => {
const plansDirName = path.basename(plansDir);
const planFilePath = `${plansDirName}/tracks/fibsqrt_20260519/spec.md`;
const params = { file_path: planFilePath, content: '# Spec' };
const invocation = tool.build(params);
expect(
(invocation as unknown as { resolvedPath: string }).resolvedPath,
).toBe(path.resolve(plansDir, 'tracks/fibsqrt_20260519/spec.md'));
});
});
});
+63 -1
View File
@@ -8,7 +8,11 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import path from 'node:path';
import * as fs from 'node:fs';
import os from 'node:os';
import { validatePlanPath, validatePlanContent } from './planUtils.js';
import {
validatePlanPath,
validatePlanContent,
resolveAndValidatePlanPath,
} from './planUtils.js';
describe('planUtils', () => {
let tempRootDir: string;
@@ -63,6 +67,64 @@ describe('planUtils', () => {
);
expect(result).toContain('Access denied');
});
it('should validate a nested path within the plans directory', async () => {
const nestedDir = path.join(plansDir, 'tracks', 'fibsqrt_20260519');
fs.mkdirSync(nestedDir, { recursive: true });
const planPath = path.join('tracks', 'fibsqrt_20260519', 'spec.md');
const fullPath = path.join(plansDir, planPath);
fs.writeFileSync(fullPath, '# Nested Spec');
const result = await validatePlanPath(planPath, plansDir, tempRootDir);
expect(result).toBeNull();
});
});
describe('resolveAndValidatePlanPath', () => {
it('should resolve simple filenames relative to plansDir', () => {
const result = resolveAndValidatePlanPath(
'implementation_plan.md',
plansDir,
tempRootDir,
);
expect(result).toBe(path.join(plansDir, 'implementation_plan.md'));
});
it('should preserve subdirectories if already inside plansDir', () => {
const planPath = path.join(
'plans',
'tracks',
'fibsqrt_20260519',
'spec.md',
);
const result = resolveAndValidatePlanPath(
planPath,
plansDir,
tempRootDir,
);
expect(result).toBe(
path.join(plansDir, 'tracks', 'fibsqrt_20260519', 'spec.md'),
);
});
it('should resolve paths relative to plansDir if they contain subdirectories', () => {
const planPath = path.join('tracks', 'fibsqrt_20260519', 'spec.md');
const result = resolveAndValidatePlanPath(
planPath,
plansDir,
tempRootDir,
);
expect(result).toBe(
path.join(plansDir, 'tracks', 'fibsqrt_20260519', 'spec.md'),
);
});
it('should throw access denied when escaping', () => {
const planPath = '../../escaped.md';
expect(() =>
resolveAndValidatePlanPath(planPath, plansDir, tempRootDir),
).toThrow(/Access denied/);
});
});
describe('validatePlanContent', () => {
+46 -33
View File
@@ -22,14 +22,6 @@ export const PlanErrorMessages = {
READ_FAILURE: (detail: string) => `Failed to read plan file: ${detail}`,
} as const;
/**
* Resolves a plan file path and strictly validates it against the plans directory boundary.
* Useful for tools that need to write or read plans.
* @param planPath The untrusted file path provided by the model.
* @param plansDir The authorized project plans directory.
* @returns The safely resolved path string.
* @throws Error if the path is empty, malicious, or escapes boundaries.
*/
export function resolveAndValidatePlanPath(
planPath: string,
plansDir: string,
@@ -40,38 +32,59 @@ export function resolveAndValidatePlanPath(
throw new Error('Plan file path must be non-empty.');
}
// 1. Handle case where agent provided an absolute path
if (path.isAbsolute(trimmedPath)) {
if (
isSubpath(resolveToRealPath(plansDir), resolveToRealPath(trimmedPath))
) {
return trimmedPath;
const realPlansDir = resolveToRealPath(plansDir);
const plansDirName = path.basename(plansDir);
let normalizedPlanPath = trimmedPath;
if (!path.isAbsolute(trimmedPath)) {
const segments = trimmedPath.split(/[\\/]+/);
if (segments.length > 1 && segments[0] === plansDirName) {
normalizedPlanPath = segments.slice(1).join(path.sep);
}
}
// 2. Handle case where agent provided a path relative to the project root
const resolvedFromProjectRoot = path.resolve(projectRoot, trimmedPath);
if (
isSubpath(
resolveToRealPath(plansDir),
resolveToRealPath(resolvedFromProjectRoot),
)
) {
return resolvedFromProjectRoot;
// 1. Handle case where agent provided an absolute path
if (path.isAbsolute(normalizedPlanPath)) {
try {
const realResolved = resolveToRealPath(normalizedPlanPath);
if (isSubpath(realPlansDir, realResolved)) {
return normalizedPlanPath;
}
} catch {
// Fall through if resolveToRealPath fails
}
}
// 3. Handle default case where agent provided a path relative to the plans directory
const resolvedPath = path.resolve(plansDir, trimmedPath);
const realPath = resolveToRealPath(resolvedPath);
const realPlansDir = resolveToRealPath(plansDir);
if (!isSubpath(realPlansDir, realPath)) {
throw new Error(
PlanErrorMessages.PATH_ACCESS_DENIED(trimmedPath, plansDir),
);
// 2. Try resolving relative to project root
const resolvedFromProjectRoot = path.resolve(projectRoot, normalizedPlanPath);
try {
const realResolved = resolveToRealPath(resolvedFromProjectRoot);
if (isSubpath(realPlansDir, realResolved)) {
return resolvedFromProjectRoot;
}
} catch {
const directResolved = path.resolve(resolvedFromProjectRoot);
if (isSubpath(realPlansDir, directResolved)) {
return resolvedFromProjectRoot;
}
}
return resolvedPath;
// 3. Try resolving relative to plansDir
const resolvedFromPlansDir = path.resolve(plansDir, normalizedPlanPath);
try {
const realResolved = resolveToRealPath(resolvedFromPlansDir);
if (isSubpath(realPlansDir, realResolved)) {
return resolvedFromPlansDir;
}
} catch {
const directResolved = path.resolve(resolvedFromPlansDir);
if (isSubpath(realPlansDir, directResolved)) {
return resolvedFromPlansDir;
}
}
// Fallback boundary check: if still not a subpath, throw PATH_ACCESS_DENIED
throw new Error(PlanErrorMessages.PATH_ACCESS_DENIED(trimmedPath, plansDir));
}
/**
+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."