mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-10 00:47:16 -07:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a3080bad7 | |||
| d94a2804ce | |||
| cfd7783c30 | |||
| fdce2643ca | |||
| 6555622b81 | |||
| 583d42b86f | |||
| d472777ac0 | |||
| a513fa5b06 | |||
| dcf47f9207 | |||
| 0c02dec524 | |||
| ba3f3cb52e | |||
| 84bb13bd73 | |||
| 251235a55b | |||
| 124a9a6d03 | |||
| a2e862e8b4 | |||
| 88d9edf345 | |||
| c6b1051174 | |||
| 5a0014e9ae | |||
| 447cab514a | |||
| 0a5bfd86ec | |||
| ac63417a59 | |||
| 5bce1f78bd | |||
| 27ed3a4d07 | |||
| 7cc657add2 | |||
| fc05f78e55 | |||
| 6d7620cb80 | |||
| bea622e5e9 | |||
| 7b39a05afb | |||
| c044bcd205 | |||
| 78754b9349 | |||
| b9d3ede51d | |||
| d43af03598 | |||
| af332c614a | |||
| 922c6af4df |
@@ -68,3 +68,4 @@ temp_agents/
|
||||
|
||||
# conductor extension and planning directories
|
||||
conductor/
|
||||
simulator_workspace_*
|
||||
|
||||
@@ -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".
|
||||
Generated
+409
-13
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Executable
+83
@@ -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."
|
||||
Reference in New Issue
Block a user