mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 16:20:57 -07:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21348834d7 | |||
| 14596de440 | |||
| 06d019e8c9 | |||
| acebeea129 | |||
| 38d2c7a409 | |||
| 0f0cc3da8d | |||
| 6f975fb955 | |||
| be33cdc896 | |||
| 844127130b | |||
| f57d436d5b | |||
| 0af62ba23d | |||
| ca59603fb6 | |||
| b8c1dfad51 | |||
| 483193257e | |||
| e9171fd792 | |||
| 2009fbbd92 | |||
| 8db2948361 | |||
| 5fa14dbe42 | |||
| e6cd5d208c | |||
| c9d48026c4 |
@@ -58,6 +58,19 @@ your total token usage using the `/stats` command in Gemini CLI.
|
||||
|
||||
## Installation and updates
|
||||
|
||||
### How do I check which version of Gemini CLI I'm currently running?
|
||||
|
||||
You can check your current Gemini CLI version using one of these methods:
|
||||
|
||||
- Run `gemini --version` or `gemini -v` from your terminal
|
||||
- Check the globally installed version using your package manager:
|
||||
- npm: `npm list -g @google/gemini-cli`
|
||||
- pnpm: `pnpm list -g @google/gemini-cli`
|
||||
- yarn: `yarn global list @google/gemini-cli`
|
||||
- bun: `bun pm ls -g @google/gemini-cli`
|
||||
- homebrew: `brew list --versions gemini-cli`
|
||||
- Inside an active Gemini CLI session, use the `/about` command
|
||||
|
||||
### How do I update Gemini CLI to the latest version?
|
||||
|
||||
If you installed it globally via `npm`, update it using the command
|
||||
|
||||
Generated
+186
-106
@@ -80,7 +80,7 @@
|
||||
"@lydell/node-pty-win32-arm64": "1.1.0",
|
||||
"@lydell/node-pty-win32-x64": "1.1.0",
|
||||
"keytar": "^7.9.0",
|
||||
"node-pty": "^1.0.0"
|
||||
"node-pty": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@agentclientprotocol/sdk": {
|
||||
@@ -4906,53 +4906,6 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/vsce": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.6.0.tgz",
|
||||
"integrity": "sha512-u2ZoMfymRNJb14aHNawnXJtXHLXDVKc1oKZaH4VELKT/9iWKRVgtQOdwxCgtwSxJoqYvuK4hGlBWQJ05wxADhg==",
|
||||
"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",
|
||||
"@vscode/vsce-sign": "^2.0.0",
|
||||
"azure-devops-node-api": "^12.5.0",
|
||||
"chalk": "^4.1.2",
|
||||
"cheerio": "^1.0.0-rc.9",
|
||||
"cockatiel": "^3.1.2",
|
||||
"commander": "^12.1.0",
|
||||
"form-data": "^4.0.0",
|
||||
"glob": "^11.0.0",
|
||||
"hosted-git-info": "^4.0.2",
|
||||
"jsonc-parser": "^3.2.0",
|
||||
"leven": "^3.1.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"mime": "^1.3.4",
|
||||
"minimatch": "^3.0.3",
|
||||
"parse-semver": "^1.1.1",
|
||||
"read": "^1.0.7",
|
||||
"secretlint": "^10.1.1",
|
||||
"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",
|
||||
"yazl": "^2.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"vsce": "vsce"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"keytar": "^7.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/vsce-sign": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.6.tgz",
|
||||
@@ -5098,52 +5051,6 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"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",
|
||||
"integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz",
|
||||
@@ -6655,6 +6562,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/config-chain": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
|
||||
@@ -12193,13 +12107,6 @@
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.23.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
|
||||
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nano-spawn": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.3.tgz",
|
||||
@@ -12343,16 +12250,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-pty": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
|
||||
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
|
||||
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"nan": "^2.17.0"
|
||||
"node-addon-api": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-pty/node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-sarif-builder": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.2.0.tgz",
|
||||
@@ -17996,6 +17910,23 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"packages/snake-game": {
|
||||
"name": "@google/gemini-cli-snake-game",
|
||||
"version": "0.1.0",
|
||||
"extraneous": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.471.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
@@ -18030,7 +17961,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",
|
||||
@@ -18047,6 +17978,155 @@
|
||||
"integrity": "sha512-30sjmas1hQ0gVbX68LAWlm/YYlEqUErunPJJKLpEl+xhK0mKn+jyzlCOpsdTwfkZfPy4U6CDkmygBLC3AB8W9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/vscode-ide-companion/node_modules/@vscode/vsce": {
|
||||
"version": "3.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.7.1.tgz",
|
||||
"integrity": "sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/identity": "^4.1.0",
|
||||
"@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",
|
||||
"cheerio": "^1.0.0-rc.9",
|
||||
"cockatiel": "^3.1.2",
|
||||
"commander": "^12.1.0",
|
||||
"form-data": "^4.0.0",
|
||||
"glob": "^11.0.0",
|
||||
"hosted-git-info": "^4.0.2",
|
||||
"jsonc-parser": "^3.2.0",
|
||||
"leven": "^3.1.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"mime": "^1.3.4",
|
||||
"minimatch": "^3.0.3",
|
||||
"parse-semver": "^1.1.1",
|
||||
"read": "^1.0.7",
|
||||
"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",
|
||||
"yazl": "^2.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"vsce": "vsce"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"keytar": "^7.7.0"
|
||||
}
|
||||
},
|
||||
"packages/vscode-ide-companion/node_modules/@vscode/vsce/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"packages/vscode-ide-companion/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/vscode-ide-companion/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"packages/vscode-ide-companion/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"
|
||||
}
|
||||
},
|
||||
"packages/vscode-ide-companion/node_modules/hosted-git-info": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
||||
"integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"packages/vscode-ide-companion/node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"packages/vscode-ide-companion/node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"packages/vscode-ide-companion/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -150,7 +150,7 @@
|
||||
"@lydell/node-pty-win32-arm64": "1.1.0",
|
||||
"@lydell/node-pty-win32-x64": "1.1.0",
|
||||
"keytar": "^7.9.0",
|
||||
"node-pty": "^1.0.0"
|
||||
"node-pty": "^1.1.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
|
||||
@@ -894,6 +894,9 @@ describe('Session', () => {
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
status: 'completed',
|
||||
title: 'Test Tool',
|
||||
locations: [],
|
||||
kind: 'read',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1306,6 +1309,18 @@ describe('Session', () => {
|
||||
expect(path.resolve).toHaveBeenCalled();
|
||||
expect(fs.stat).toHaveBeenCalled();
|
||||
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
status: 'completed',
|
||||
title: 'Read files',
|
||||
locations: [],
|
||||
kind: 'read',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify ReadManyFilesTool was used (implicitly by checking if sendMessageStream was called with resolved content)
|
||||
// Since we mocked ReadManyFilesTool to return specific content, we can check the args passed to sendMessageStream
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
|
||||
@@ -1321,6 +1336,65 @@ describe('Session', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle @path resolution error', async () => {
|
||||
(path.resolve as unknown as Mock).mockReturnValue('/tmp/error.txt');
|
||||
(fs.stat as unknown as Mock).mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
});
|
||||
(isWithinRoot as unknown as Mock).mockReturnValue(true);
|
||||
|
||||
const MockReadManyFilesTool = ReadManyFilesTool as unknown as Mock;
|
||||
MockReadManyFilesTool.mockImplementationOnce(() => ({
|
||||
name: 'read_many_files',
|
||||
kind: 'read',
|
||||
build: vi.fn().mockReturnValue({
|
||||
getDescription: () => 'Read files',
|
||||
toolLocations: () => [],
|
||||
execute: vi.fn().mockRejectedValue(new Error('File read failed')),
|
||||
}),
|
||||
}));
|
||||
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||
|
||||
await expect(
|
||||
session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [
|
||||
{ type: 'text', text: 'Read' },
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file://error.txt',
|
||||
mimeType: 'text/plain',
|
||||
name: 'error.txt',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow('File read failed');
|
||||
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
status: 'failed',
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
content: expect.objectContaining({
|
||||
text: expect.stringMatching(/File read failed/),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
kind: 'read',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle cancellation during prompt', async () => {
|
||||
let streamController: ReadableStreamDefaultController<unknown>;
|
||||
const stream = new ReadableStream({
|
||||
@@ -1434,6 +1508,7 @@ describe('Session', () => {
|
||||
content: expect.objectContaining({ text: 'Tool failed' }),
|
||||
}),
|
||||
]),
|
||||
kind: 'read',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -966,7 +966,10 @@ export class Session {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: callId,
|
||||
status: 'completed',
|
||||
title: invocation.getDescription(),
|
||||
content: content ? [content] : [],
|
||||
locations: invocation.toolLocations(),
|
||||
kind: toAcpToolKind(tool.kind),
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
@@ -1030,6 +1033,7 @@ export class Session {
|
||||
content: [
|
||||
{ type: 'content', content: { type: 'text', text: error.message } },
|
||||
],
|
||||
kind: toAcpToolKind(tool.kind),
|
||||
});
|
||||
|
||||
this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [
|
||||
@@ -1324,7 +1328,10 @@ export class Session {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: callId,
|
||||
status: 'completed',
|
||||
title: invocation.getDescription(),
|
||||
content: content ? [content] : [],
|
||||
locations: invocation.toolLocations(),
|
||||
kind: toAcpToolKind(readManyFilesTool.kind),
|
||||
});
|
||||
if (Array.isArray(result.llmContent)) {
|
||||
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
|
||||
@@ -1368,6 +1375,7 @@ export class Session {
|
||||
},
|
||||
},
|
||||
],
|
||||
kind: toAcpToolKind(readManyFilesTool.kind),
|
||||
});
|
||||
|
||||
throw error;
|
||||
|
||||
@@ -97,6 +97,8 @@ export interface CliArgs {
|
||||
rawOutput: boolean | undefined;
|
||||
acceptRawOutputRisk: boolean | undefined;
|
||||
isCommand: boolean | undefined;
|
||||
simulateUser: boolean | undefined;
|
||||
knowledgeSources: string[] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -298,6 +300,19 @@ 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-sources', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
nargs: 1,
|
||||
description:
|
||||
'List of files or folders to load into the user simulator context (comma-separated or multiple --knowledge-sources)',
|
||||
coerce: coerceCommaSeparated,
|
||||
}),
|
||||
)
|
||||
// Register MCP subcommands
|
||||
@@ -799,6 +814,8 @@ export async function loadCliConfig(
|
||||
approvalMode,
|
||||
disableYoloMode:
|
||||
settings.security?.disableYoloMode || settings.admin?.secureModeEnabled,
|
||||
simulateUser: !!argv.simulateUser,
|
||||
knowledgeSources: argv.knowledgeSources,
|
||||
disableAlwaysAllow:
|
||||
settings.security?.disableAlwaysAllow ||
|
||||
settings.admin?.secureModeEnabled,
|
||||
|
||||
@@ -513,6 +513,8 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
simulateUser: undefined,
|
||||
knowledgeSources: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -9,7 +9,15 @@ import { render } from 'ink';
|
||||
import { basename } from 'node:path';
|
||||
import { AppContainer } from './ui/AppContainer.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { UserSimulator } from './services/UserSimulator.js';
|
||||
import { registerCleanup, setupTtyCheck } from './utils/cleanup.js';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
interface RenderMetrics {
|
||||
renderTime: number;
|
||||
output: string;
|
||||
staticOutput?: string;
|
||||
}
|
||||
import {
|
||||
type StartupWarning,
|
||||
type Config,
|
||||
@@ -132,6 +140,11 @@ export async function startInteractiveUI(
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const simulateUser = config.getSimulateUser();
|
||||
const simulatedStdin = new PassThrough({ encoding: 'utf8' });
|
||||
|
||||
let lastFrame: string | undefined;
|
||||
const staticHistory: string[] = [];
|
||||
const instance = render(
|
||||
process.env['DEBUG'] ? (
|
||||
<React.StrictMode>
|
||||
@@ -143,12 +156,20 @@ export async function startInteractiveUI(
|
||||
{
|
||||
stdout: inkStdout,
|
||||
stderr: inkStderr,
|
||||
stdin: process.stdin,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
stdin: (simulateUser ? simulatedStdin : process.stdin) as any,
|
||||
exitOnCtrlC: false,
|
||||
isScreenReaderEnabled: config.getScreenReader(),
|
||||
onRender: ({ renderTime }: { renderTime: number }) => {
|
||||
if (renderTime > SLOW_RENDER_MS) {
|
||||
recordSlowRender(config, renderTime);
|
||||
onRender: (metrics: RenderMetrics) => {
|
||||
lastFrame = metrics.output;
|
||||
if (metrics.staticOutput) {
|
||||
staticHistory.push(metrics.staticOutput);
|
||||
if (staticHistory.length > 50) {
|
||||
staticHistory.shift();
|
||||
}
|
||||
}
|
||||
if (metrics.renderTime > SLOW_RENDER_MS) {
|
||||
recordSlowRender(config, metrics.renderTime);
|
||||
}
|
||||
profiler.reportFrameRendered();
|
||||
},
|
||||
@@ -179,6 +200,21 @@ export async function startInteractiveUI(
|
||||
}
|
||||
});
|
||||
|
||||
if (simulateUser) {
|
||||
const simulator = new UserSimulator(
|
||||
config,
|
||||
() => {
|
||||
if (lastFrame === undefined) return undefined;
|
||||
// Combine history with latest frame for the simulator
|
||||
const historyText = staticHistory.join('\n');
|
||||
return historyText ? `${historyText}\n${lastFrame}` : lastFrame;
|
||||
},
|
||||
simulatedStdin,
|
||||
);
|
||||
simulator.start();
|
||||
registerCleanup(() => simulator.stop());
|
||||
}
|
||||
|
||||
registerCleanup(() => instance.unmount());
|
||||
|
||||
registerCleanup(setupTtyCheck());
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
LlmRole,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
resolveModel,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Writable } from 'node:stream';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export class UserSimulator {
|
||||
private isRunning = false;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private lastScreenContent = '';
|
||||
private isProcessing = false;
|
||||
private interactionsFile: string | null = null;
|
||||
|
||||
private knowledgeBase = '';
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly getScreen: () => string | undefined,
|
||||
private readonly stdinBuffer: Writable,
|
||||
) {}
|
||||
|
||||
start() {
|
||||
if (!this.config.getSimulateUser()) {
|
||||
return;
|
||||
}
|
||||
const sources = (this.config as any).getKnowledgeSources?.() || [];
|
||||
if (sources.length > 0) {
|
||||
this.loadKnowledge(sources);
|
||||
}
|
||||
this.interactionsFile = `interactions_${Date.now()}.txt`;
|
||||
this.isRunning = true;
|
||||
this.timer = setInterval(() => this.tick(), 3000);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isRunning = false;
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
debugLogger.log('User simulator stopped');
|
||||
}
|
||||
|
||||
private loadKnowledge(paths: string[]) {
|
||||
const loadFromPath = (p: string) => {
|
||||
try {
|
||||
if (!fs.existsSync(p)) return;
|
||||
const stats = fs.statSync(p);
|
||||
if (stats.isFile()) {
|
||||
const content = fs.readFileSync(p, 'utf-8');
|
||||
this.knowledgeBase += `\nFile: ${p}\nContent:\n${content}\n---\n`;
|
||||
} else if (stats.isDirectory()) {
|
||||
const files = fs.readdirSync(p);
|
||||
for (const file of files) {
|
||||
loadFromPath(path.join(p, file));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugLogger.error(`Failed to load knowledge from ${p}`, e);
|
||||
}
|
||||
};
|
||||
|
||||
for (const p of paths) {
|
||||
loadFromPath(p);
|
||||
}
|
||||
}
|
||||
|
||||
private async tick() {
|
||||
if (!this.isRunning || this.isProcessing) return;
|
||||
|
||||
try {
|
||||
this.isProcessing = true;
|
||||
const screen = this.getScreen();
|
||||
if (!screen) return;
|
||||
|
||||
const strippedScreen = screen
|
||||
.replace(
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
|
||||
'',
|
||||
)
|
||||
.replace(/\n([ \t]*\n)+/g, '\n\n');
|
||||
|
||||
const normalizedScreen = strippedScreen
|
||||
.replace(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/g, '')
|
||||
.replace(/\[?\s*\b\d+(\.\d+)?s\b\s*\]?/g, '')
|
||||
.trim();
|
||||
|
||||
if (normalizedScreen === this.lastScreenContent) return;
|
||||
|
||||
debugLogger.log(
|
||||
`[SIMULATOR] Screen Content Seen:\n---\n${strippedScreen}\n---`,
|
||||
);
|
||||
if (this.interactionsFile) {
|
||||
fs.appendFileSync(
|
||||
this.interactionsFile,
|
||||
`[LOG] [SIMULATOR] Screen Content Seen:\n---\n${strippedScreen}\n---\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const contentGenerator = this.config.getContentGenerator();
|
||||
if (!contentGenerator) return;
|
||||
|
||||
const originalGoal = this.config.getQuestion();
|
||||
const goalInstruction = originalGoal
|
||||
? `\nThe original goal was: "${originalGoal}"\n`
|
||||
: '';
|
||||
|
||||
|
||||
|
||||
const knowledgeInstruction = this.knowledgeBase
|
||||
? `\nUser Knowledge Base:\nUse this information to answer questions if applicable. If the answer is not here, respond as you normally would.\n${this.knowledgeBase}\n`
|
||||
: '';
|
||||
|
||||
const prompt = `You are evaluating a CLI agent by simulating a user sitting at the terminal.
|
||||
Look carefully at the screen and determine the CLI's current state:
|
||||
|
||||
STATE 1: The agent is busy (e.g., streaming a response, showing a spinner, running a tool, or displaying a timer like "7s"). It is actively working and NOT waiting for text input.
|
||||
- In this case, you MUST output exactly: <WAIT>
|
||||
|
||||
STATE 2: The agent is waiting for you to authorize a tool, confirm an action, or answer a specific multi-choice question (e.g., "Action Required", "Allow execution", numbered options).
|
||||
- In this case, you MUST output the exact raw characters to select the option and submit it (e.g., 1\\r, 2\\r, y\\r, n\\r, or just \\r if the default option is acceptable). Do NOT output <DONE> or "Thank you". You must unblock the agent and allow it to run the tool.
|
||||
|
||||
STATE 3: The agent has finished its current thought process AND is idle, waiting for a NEW general text prompt (usually indicated by a "> Type your message" prompt).
|
||||
- First, verify that the ACTUAL task is fully complete based on your original goal. Do not stop at intermediate steps like planning or syntax checking.
|
||||
- If the task is indeed fully complete, output "Thank you\\r" to graciously finish the simulation.
|
||||
- If you have already said thank you, output 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.
|
||||
- Output the raw characters you would type, followed by \\r. For just an Enter key press, output \\r.
|
||||
|
||||
CRITICAL RULES:
|
||||
- RULE 1: If there is ANY active spinner (e.g., ⠋, ⠙, ⠹, ⠸, ⠼, ⠴, ⠧) or an elapsed time indicator (e.g., "0s", "7s") anywhere on the screen, the agent is STILL WORKING. You MUST output <WAIT>. Do NOT issue commands, even if a text prompt is visible below it.
|
||||
- RULE 2: If there is an "Action Required" or confirmation prompt on the screen, YOU MUST HANDLE IT (State 2). This takes precedence over everything else.
|
||||
- RULE 3: Output ONLY the raw characters to send, <WAIT>, or <DONE>.
|
||||
- RULE 4: Do NOT output markdown, explanations of your thought process, or quotes.
|
||||
${goalInstruction}${knowledgeInstruction}
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
const responseText = (response.text || '').replace(
|
||||
/^[`"']+|[`"']+$/g,
|
||||
'',
|
||||
);
|
||||
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.lastScreenContent = normalizedScreen;
|
||||
return;
|
||||
}
|
||||
|
||||
if (responseText) {
|
||||
const keys = responseText.replace(/\\n/g, '\r').replace(/\\r/g, '\r');
|
||||
|
||||
|
||||
debugLogger.log(
|
||||
`[SIMULATOR] Sending to stdin: ${JSON.stringify(keys)}`,
|
||||
);
|
||||
this.stdinBuffer.write(keys);
|
||||
this.lastScreenContent = normalizedScreen;
|
||||
} else {
|
||||
debugLogger.log('[SIMULATOR] Skipping (empty response)');
|
||||
this.lastScreenContent = normalizedScreen;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
debugLogger.error('UserSimulator tick failed', e);
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,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'),
|
||||
|
||||
@@ -202,7 +202,12 @@ describe('handleAutoUpdate', () => {
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([PackageManager.NPX, PackageManager.PNPX, PackageManager.BUNX])(
|
||||
it.each([
|
||||
PackageManager.NPX,
|
||||
PackageManager.PNPX,
|
||||
PackageManager.BUNX,
|
||||
PackageManager.BINARY,
|
||||
])(
|
||||
'should suppress update notifications when running via %s',
|
||||
(packageManager) => {
|
||||
mockGetInstallationInfo.mockReturnValue({
|
||||
|
||||
@@ -87,9 +87,12 @@ export function handleAutoUpdate(
|
||||
);
|
||||
|
||||
if (
|
||||
[PackageManager.NPX, PackageManager.PNPX, PackageManager.BUNX].includes(
|
||||
installationInfo.packageManager,
|
||||
)
|
||||
[
|
||||
PackageManager.NPX,
|
||||
PackageManager.PNPX,
|
||||
PackageManager.BUNX,
|
||||
PackageManager.BINARY,
|
||||
].includes(installationInfo.packageManager)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,19 @@ describe('getInstallationInfo', () => {
|
||||
process.argv = originalArgv;
|
||||
});
|
||||
|
||||
it('should detect running as a standalone binary', () => {
|
||||
vi.stubEnv('IS_BINARY', 'true');
|
||||
process.argv[1] = '/path/to/binary';
|
||||
const info = getInstallationInfo(projectRoot, true);
|
||||
expect(info.packageManager).toBe(PackageManager.BINARY);
|
||||
expect(info.isGlobal).toBe(true);
|
||||
expect(info.updateMessage).toBe(
|
||||
'Running as a standalone binary. Please update by downloading the latest version from GitHub.',
|
||||
);
|
||||
expect(info.updateCommand).toBeUndefined();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should return UNKNOWN when cliPath is not available', () => {
|
||||
process.argv[1] = '';
|
||||
const info = getInstallationInfo(projectRoot, true);
|
||||
|
||||
@@ -21,6 +21,7 @@ export enum PackageManager {
|
||||
BUNX = 'bunx',
|
||||
HOMEBREW = 'homebrew',
|
||||
NPX = 'npx',
|
||||
BINARY = 'binary',
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
|
||||
@@ -41,6 +42,16 @@ export function getInstallationInfo(
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for standalone binary first
|
||||
if (process.env['IS_BINARY'] === 'true') {
|
||||
return {
|
||||
packageManager: PackageManager.BINARY,
|
||||
isGlobal: true,
|
||||
updateMessage:
|
||||
'Running as a standalone binary. Please update by downloading the latest version from GitHub.',
|
||||
};
|
||||
}
|
||||
|
||||
// Normalize path separators to forward slashes for consistent matching.
|
||||
const realPath = fs.realpathSync(cliPath).replace(/\\/g, '/');
|
||||
const normalizedProjectRoot = projectRoot?.replace(/\\/g, '/');
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
geminiPartsToContentParts,
|
||||
contentPartsToGeminiParts,
|
||||
toolResultDisplayToContentParts,
|
||||
buildToolResponseData,
|
||||
} from './content-utils.js';
|
||||
import type { Part } from '@google/genai';
|
||||
import type { ContentPart } from './types.js';
|
||||
|
||||
describe('geminiPartsToContentParts', () => {
|
||||
it('converts text parts', () => {
|
||||
const parts: Part[] = [{ text: 'hello' }];
|
||||
expect(geminiPartsToContentParts(parts)).toEqual([
|
||||
{ type: 'text', text: 'hello' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts thought parts', () => {
|
||||
const parts: Part[] = [
|
||||
{ text: 'thinking...', thought: true, thoughtSignature: 'sig123' },
|
||||
];
|
||||
expect(geminiPartsToContentParts(parts)).toEqual([
|
||||
{
|
||||
type: 'thought',
|
||||
thought: 'thinking...',
|
||||
thoughtSignature: 'sig123',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts thought parts without signature', () => {
|
||||
const parts: Part[] = [{ text: 'thinking...', thought: true }];
|
||||
expect(geminiPartsToContentParts(parts)).toEqual([
|
||||
{ type: 'thought', thought: 'thinking...' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts inlineData parts to media', () => {
|
||||
const parts: Part[] = [
|
||||
{ inlineData: { data: 'base64data', mimeType: 'image/png' } },
|
||||
];
|
||||
expect(geminiPartsToContentParts(parts)).toEqual([
|
||||
{ type: 'media', data: 'base64data', mimeType: 'image/png' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts fileData parts to media', () => {
|
||||
const parts: Part[] = [
|
||||
{
|
||||
fileData: {
|
||||
fileUri: 'gs://bucket/file.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(geminiPartsToContentParts(parts)).toEqual([
|
||||
{
|
||||
type: 'media',
|
||||
uri: 'gs://bucket/file.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips functionCall parts', () => {
|
||||
const parts: Part[] = [
|
||||
{ functionCall: { name: 'myFunc', args: { key: 'value' } } },
|
||||
];
|
||||
const result = geminiPartsToContentParts(parts);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips functionResponse parts', () => {
|
||||
const parts: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'myFunc',
|
||||
response: { output: 'result' },
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = geminiPartsToContentParts(parts);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('serializes unknown part types to text with _meta', () => {
|
||||
const parts: Part[] = [{ unknownField: 'data' } as Part];
|
||||
const result = geminiPartsToContentParts(parts);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.type).toBe('text');
|
||||
expect(result[0]?._meta).toEqual({ partType: 'unknown' });
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
expect(geminiPartsToContentParts([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles mixed parts', () => {
|
||||
const parts: Part[] = [
|
||||
{ text: 'hello' },
|
||||
{ inlineData: { data: 'img', mimeType: 'image/jpeg' } },
|
||||
{ text: 'thought', thought: true },
|
||||
];
|
||||
const result = geminiPartsToContentParts(parts);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]?.type).toBe('text');
|
||||
expect(result[1]?.type).toBe('media');
|
||||
expect(result[2]?.type).toBe('thought');
|
||||
});
|
||||
});
|
||||
|
||||
describe('contentPartsToGeminiParts', () => {
|
||||
it('converts text ContentParts', () => {
|
||||
const content: ContentPart[] = [{ type: 'text', text: 'hello' }];
|
||||
expect(contentPartsToGeminiParts(content)).toEqual([{ text: 'hello' }]);
|
||||
});
|
||||
|
||||
it('converts thought ContentParts', () => {
|
||||
const content: ContentPart[] = [
|
||||
{ type: 'thought', thought: 'thinking...', thoughtSignature: 'sig' },
|
||||
];
|
||||
expect(contentPartsToGeminiParts(content)).toEqual([
|
||||
{ text: 'thinking...', thought: true, thoughtSignature: 'sig' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts thought ContentParts without signature', () => {
|
||||
const content: ContentPart[] = [
|
||||
{ type: 'thought', thought: 'thinking...' },
|
||||
];
|
||||
expect(contentPartsToGeminiParts(content)).toEqual([
|
||||
{ text: 'thinking...', thought: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts media ContentParts with data to inlineData', () => {
|
||||
const content: ContentPart[] = [
|
||||
{ type: 'media', data: 'base64', mimeType: 'image/png' },
|
||||
];
|
||||
expect(contentPartsToGeminiParts(content)).toEqual([
|
||||
{ inlineData: { data: 'base64', mimeType: 'image/png' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts media ContentParts with uri to fileData', () => {
|
||||
const content: ContentPart[] = [
|
||||
{ type: 'media', uri: 'gs://bucket/file', mimeType: 'application/pdf' },
|
||||
];
|
||||
expect(contentPartsToGeminiParts(content)).toEqual([
|
||||
{
|
||||
fileData: { fileUri: 'gs://bucket/file', mimeType: 'application/pdf' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts reference ContentParts to text', () => {
|
||||
const content: ContentPart[] = [{ type: 'reference', text: '@file.ts' }];
|
||||
expect(contentPartsToGeminiParts(content)).toEqual([{ text: '@file.ts' }]);
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
expect(contentPartsToGeminiParts([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips media parts with no data or uri', () => {
|
||||
const content: ContentPart[] = [{ type: 'media', mimeType: 'image/png' }];
|
||||
expect(contentPartsToGeminiParts(content)).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults mimeType for media with data but no mimeType', () => {
|
||||
const content: ContentPart[] = [{ type: 'media', data: 'base64data' }];
|
||||
const result = contentPartsToGeminiParts(content);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
inlineData: {
|
||||
data: 'base64data',
|
||||
mimeType: 'application/octet-stream',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializes unknown ContentPart variants', () => {
|
||||
// Force an unknown variant past the type system
|
||||
const content = [
|
||||
{ type: 'custom_widget', payload: 123 },
|
||||
] as unknown as ContentPart[];
|
||||
const result = contentPartsToGeminiParts(content);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
text: JSON.stringify({ type: 'custom_widget', payload: 123 }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toolResultDisplayToContentParts', () => {
|
||||
it('returns undefined for undefined', () => {
|
||||
expect(toolResultDisplayToContentParts(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for null', () => {
|
||||
expect(toolResultDisplayToContentParts(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles string resultDisplay as-is', () => {
|
||||
const result = toolResultDisplayToContentParts('File written');
|
||||
expect(result).toEqual([{ type: 'text', text: 'File written' }]);
|
||||
});
|
||||
|
||||
it('stringifies object resultDisplay', () => {
|
||||
const display = { type: 'FileDiff', oldPath: 'a.ts', newPath: 'b.ts' };
|
||||
const result = toolResultDisplayToContentParts(display);
|
||||
expect(result).toEqual([{ type: 'text', text: JSON.stringify(display) }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildToolResponseData', () => {
|
||||
it('preserves outputFile and contentLength', () => {
|
||||
const result = buildToolResponseData({
|
||||
outputFile: '/tmp/result.txt',
|
||||
contentLength: 256,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
outputFile: '/tmp/result.txt',
|
||||
contentLength: 256,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined for empty response', () => {
|
||||
const result = buildToolResponseData({});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes errorType when present', () => {
|
||||
const result = buildToolResponseData({
|
||||
errorType: 'permission_denied',
|
||||
});
|
||||
expect(result).toEqual({ errorType: 'permission_denied' });
|
||||
});
|
||||
|
||||
it('merges data with other fields', () => {
|
||||
const result = buildToolResponseData({
|
||||
data: { custom: 'value' },
|
||||
outputFile: '/tmp/file.txt',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
custom: 'value',
|
||||
outputFile: '/tmp/file.txt',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Part } from '@google/genai';
|
||||
import type { ContentPart } from './types.js';
|
||||
|
||||
/**
|
||||
* Converts Gemini API Part objects to framework-agnostic ContentPart objects.
|
||||
* Handles text, thought, inlineData, fileData parts and serializes unknown
|
||||
* part types to text to avoid silent data loss.
|
||||
*/
|
||||
export function geminiPartsToContentParts(parts: Part[]): ContentPart[] {
|
||||
const result: ContentPart[] = [];
|
||||
for (const part of parts) {
|
||||
if ('text' in part && part.text !== undefined) {
|
||||
if ('thought' in part && part.thought) {
|
||||
result.push({
|
||||
type: 'thought',
|
||||
thought: part.text,
|
||||
...(part.thoughtSignature
|
||||
? { thoughtSignature: part.thoughtSignature }
|
||||
: {}),
|
||||
});
|
||||
} else {
|
||||
result.push({ type: 'text', text: part.text });
|
||||
}
|
||||
} else if ('inlineData' in part && part.inlineData) {
|
||||
result.push({
|
||||
type: 'media',
|
||||
data: part.inlineData.data,
|
||||
mimeType: part.inlineData.mimeType,
|
||||
});
|
||||
} else if ('fileData' in part && part.fileData) {
|
||||
result.push({
|
||||
type: 'media',
|
||||
uri: part.fileData.fileUri,
|
||||
mimeType: part.fileData.mimeType,
|
||||
});
|
||||
} else if ('functionCall' in part && part.functionCall) {
|
||||
continue; // Skip function calls, they are emitted as distinct tool_request events
|
||||
} else if ('functionResponse' in part && part.functionResponse) {
|
||||
continue; // Skip function responses, they are tied to tool_response events
|
||||
} else {
|
||||
// Fallback: serialize any unrecognized part type to text
|
||||
result.push({
|
||||
type: 'text',
|
||||
text: JSON.stringify(part),
|
||||
_meta: { partType: 'unknown' },
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts framework-agnostic ContentPart objects to Gemini API Part objects.
|
||||
*/
|
||||
export function contentPartsToGeminiParts(content: ContentPart[]): Part[] {
|
||||
const result: Part[] = [];
|
||||
for (const part of content) {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
result.push({ text: part.text });
|
||||
break;
|
||||
case 'thought':
|
||||
result.push({
|
||||
text: part.thought,
|
||||
thought: true,
|
||||
...(part.thoughtSignature
|
||||
? { thoughtSignature: part.thoughtSignature }
|
||||
: {}),
|
||||
});
|
||||
break;
|
||||
case 'media':
|
||||
if (part.data) {
|
||||
result.push({
|
||||
inlineData: {
|
||||
data: part.data,
|
||||
mimeType: part.mimeType ?? 'application/octet-stream',
|
||||
},
|
||||
});
|
||||
} else if (part.uri) {
|
||||
result.push({
|
||||
fileData: { fileUri: part.uri, mimeType: part.mimeType },
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'reference':
|
||||
// References are converted to text for the model
|
||||
result.push({ text: part.text });
|
||||
break;
|
||||
default:
|
||||
// Serialize unknown ContentPart variants instead of dropping them
|
||||
result.push({ text: JSON.stringify(part) });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ToolCallResponseInfo.resultDisplay value into ContentPart[].
|
||||
* Handles string, object-valued (FileDiff, SubagentProgress, etc.),
|
||||
* and undefined resultDisplay consistently.
|
||||
*/
|
||||
export function toolResultDisplayToContentParts(
|
||||
resultDisplay: unknown,
|
||||
): ContentPart[] | undefined {
|
||||
if (resultDisplay === undefined || resultDisplay === null) {
|
||||
return undefined;
|
||||
}
|
||||
const text =
|
||||
typeof resultDisplay === 'string'
|
||||
? resultDisplay
|
||||
: JSON.stringify(resultDisplay);
|
||||
return [{ type: 'text', text }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the data record for a tool_response AgentEvent, preserving
|
||||
* all available metadata from the ToolCallResponseInfo.
|
||||
*/
|
||||
export function buildToolResponseData(response: {
|
||||
data?: Record<string, unknown>;
|
||||
errorType?: string;
|
||||
outputFile?: string;
|
||||
contentLength?: number;
|
||||
}): Record<string, unknown> | undefined {
|
||||
const parts: Record<string, unknown> = {};
|
||||
if (response.data) Object.assign(parts, response.data);
|
||||
if (response.errorType) parts['errorType'] = response.errorType;
|
||||
if (response.outputFile) parts['outputFile'] = response.outputFile;
|
||||
if (response.contentLength !== undefined)
|
||||
parts['contentLength'] = response.contentLength;
|
||||
return Object.keys(parts).length > 0 ? parts : undefined;
|
||||
}
|
||||
@@ -91,9 +91,18 @@ import {
|
||||
type LocalAgentDefinition,
|
||||
type SubagentActivityEvent,
|
||||
type OutputConfig,
|
||||
SubagentActivityErrorType,
|
||||
} from './types.js';
|
||||
import type { AnyDeclarativeTool, AnyToolInvocation } from '../tools/tools.js';
|
||||
import type { ToolCallRequestInfo } from '../scheduler/types.js';
|
||||
import {
|
||||
ToolConfirmationOutcome,
|
||||
type AnyDeclarativeTool,
|
||||
type AnyToolInvocation,
|
||||
} from '../tools/tools.js';
|
||||
import {
|
||||
type ToolCallRequestInfo,
|
||||
CoreToolCallStatus,
|
||||
} from '../scheduler/types.js';
|
||||
|
||||
import { CompressionStatus } from '../core/turn.js';
|
||||
import { ChatCompressionService } from '../services/chatCompressionService.js';
|
||||
import type {
|
||||
@@ -1013,6 +1022,7 @@ describe('LocalAgentExecutor', () => {
|
||||
data: expect.objectContaining({
|
||||
context: 'protocol_violation',
|
||||
error: expectedError,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1058,6 +1068,7 @@ describe('LocalAgentExecutor', () => {
|
||||
context: 'tool_call',
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
error: expectedError,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1161,7 +1172,7 @@ describe('LocalAgentExecutor', () => {
|
||||
if (callsStarted === 2) resolveCalls();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
return {
|
||||
status: 'success',
|
||||
status: CoreToolCallStatus.Success,
|
||||
request: reqInfo,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
@@ -1179,7 +1190,7 @@ describe('LocalAgentExecutor', () => {
|
||||
],
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
contentLength: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
@@ -1217,10 +1228,10 @@ describe('LocalAgentExecutor', () => {
|
||||
expect(parts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
functionResponse: expect.objectContaining({ id: 'c1' }),
|
||||
functionResponse: expect.objectContaining({ name: LS_TOOL_NAME }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionResponse: expect.objectContaining({ id: 'c2' }),
|
||||
functionResponse: expect.objectContaining({ name: LS_TOOL_NAME }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
@@ -1291,6 +1302,7 @@ describe('LocalAgentExecutor', () => {
|
||||
data: expect.objectContaining({
|
||||
context: 'tool_call_unauthorized',
|
||||
name: READ_FILE_TOOL_NAME,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1344,6 +1356,7 @@ describe('LocalAgentExecutor', () => {
|
||||
context: 'tool_call',
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
error: expect.stringContaining('Output validation failed'),
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1390,6 +1403,7 @@ describe('LocalAgentExecutor', () => {
|
||||
type: 'ERROR',
|
||||
data: expect.objectContaining({
|
||||
error: `Error: Failed to create chat object: ${getErrorMessage(initError)}`,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1418,7 +1432,7 @@ describe('LocalAgentExecutor', () => {
|
||||
]);
|
||||
mockScheduleAgentTools.mockResolvedValueOnce([
|
||||
{
|
||||
status: 'error',
|
||||
status: CoreToolCallStatus.Error,
|
||||
request: {
|
||||
callId: 'call1',
|
||||
name: LS_TOOL_NAME,
|
||||
@@ -1469,6 +1483,7 @@ describe('LocalAgentExecutor', () => {
|
||||
context: 'tool_call',
|
||||
name: LS_TOOL_NAME,
|
||||
error: toolErrorMessage,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1491,6 +1506,157 @@ describe('LocalAgentExecutor', () => {
|
||||
expect(output.terminate_reason).toBe(AgentTerminateMode.GOAL);
|
||||
expect(output.result).toBe('Aborted due to tool failure.');
|
||||
});
|
||||
|
||||
it('should handle a soft tool rejection (outcome: Cancel) and provide direct instructions to the model', async () => {
|
||||
const definition = createTestDefinition([LS_TOOL_NAME]);
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
// Turn 1: Model calls a tool that will be rejected
|
||||
mockModelResponse([
|
||||
{ name: LS_TOOL_NAME, args: { path: '/secret' }, id: 'call1' },
|
||||
]);
|
||||
mockScheduleAgentTools.mockResolvedValueOnce([
|
||||
{
|
||||
status: 'cancelled',
|
||||
request: {
|
||||
callId: 'call1',
|
||||
name: LS_TOOL_NAME,
|
||||
args: { path: '/secret' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'test-prompt',
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
outcome: ToolConfirmationOutcome.Cancel, // Soft rejection
|
||||
response: {
|
||||
callId: 'call1',
|
||||
resultDisplay: '',
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: LS_TOOL_NAME,
|
||||
response: {
|
||||
error:
|
||||
'[Operation Cancelled] Reason: User denied execution.',
|
||||
},
|
||||
id: 'call1',
|
||||
},
|
||||
},
|
||||
],
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Turn 2: Model sees the rejection + consolidated instructions and completes
|
||||
mockModelResponse([
|
||||
{
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
args: { finalResult: 'User rejected access to /secret.' },
|
||||
id: 'call2',
|
||||
},
|
||||
]);
|
||||
|
||||
const output = await executor.run(
|
||||
{ goal: 'Soft rejection test' },
|
||||
signal,
|
||||
);
|
||||
|
||||
// Verify the activity stream reported the consolidated instruction
|
||||
expect(activities).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'ERROR',
|
||||
data: expect.objectContaining({
|
||||
context: 'tool_call',
|
||||
name: LS_TOOL_NAME,
|
||||
error: expect.stringContaining('User rejected this operation'),
|
||||
errorType: SubagentActivityErrorType.REJECTED,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify the instruction was sent back to the model as the tool error
|
||||
const turn2Params = getMockMessageParams(1);
|
||||
const parts = turn2Params.message as Part[];
|
||||
const errorMsg = parts[0].functionResponse?.response?.['error'];
|
||||
expect(typeof errorMsg).toBe('string');
|
||||
if (typeof errorMsg === 'string') {
|
||||
expect(errorMsg).toContain('User rejected this operation');
|
||||
expect(errorMsg).toContain('acknowledge this, rethink your strategy');
|
||||
}
|
||||
|
||||
expect(output.terminate_reason).toBe(AgentTerminateMode.GOAL);
|
||||
expect(output.result).toBe('User rejected access to /secret.');
|
||||
});
|
||||
|
||||
it('should handle a hard tool abort (cancelled with no outcome) and terminate the agent', async () => {
|
||||
const definition = createTestDefinition([LS_TOOL_NAME]);
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
// Turn 1: Model calls a tool that will be aborted (e.g. Ctrl+C)
|
||||
mockModelResponse([
|
||||
{ name: LS_TOOL_NAME, args: { path: '/secret' }, id: 'call1' },
|
||||
]);
|
||||
mockScheduleAgentTools.mockResolvedValueOnce([
|
||||
{
|
||||
status: 'cancelled',
|
||||
request: {
|
||||
callId: 'call1',
|
||||
name: LS_TOOL_NAME,
|
||||
args: { path: '/secret' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'test-prompt',
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
outcome: undefined, // Hard abort
|
||||
response: {
|
||||
callId: 'call1',
|
||||
resultDisplay: '',
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: LS_TOOL_NAME,
|
||||
response: { error: 'Request cancelled.' },
|
||||
id: 'call1',
|
||||
},
|
||||
},
|
||||
],
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const output = await executor.run({ goal: 'Hard abort test' }, signal);
|
||||
|
||||
// Verify the activity stream reported the cancellation
|
||||
expect(activities).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'ERROR',
|
||||
data: expect.objectContaining({
|
||||
context: 'tool_call',
|
||||
name: LS_TOOL_NAME,
|
||||
error: 'Request cancelled.',
|
||||
errorType: SubagentActivityErrorType.CANCELLED,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Agent should terminate with ABORTED status
|
||||
expect(output.terminate_reason).toBe(AgentTerminateMode.ABORTED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Model Routing', () => {
|
||||
@@ -1685,6 +1851,7 @@ describe('LocalAgentExecutor', () => {
|
||||
data: expect.objectContaining({
|
||||
context: 'timeout',
|
||||
error: 'Agent timed out after 0.5 minutes.',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1873,6 +2040,7 @@ describe('LocalAgentExecutor', () => {
|
||||
data: expect.objectContaining({
|
||||
context: 'recovery_turn',
|
||||
error: 'Graceful recovery attempt failed. Reason: stop',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1956,6 +2124,7 @@ describe('LocalAgentExecutor', () => {
|
||||
data: expect.objectContaining({
|
||||
context: 'recovery_turn',
|
||||
error: 'Graceful recovery attempt failed. Reason: stop',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -2077,6 +2246,7 @@ describe('LocalAgentExecutor', () => {
|
||||
data: expect.objectContaining({
|
||||
context: 'recovery_turn',
|
||||
error: 'Graceful recovery attempt failed. Reason: stop',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import { ResourceRegistry } from '../resources/resource-registry.js';
|
||||
import { type AnyDeclarativeTool } from '../tools/tools.js';
|
||||
import {
|
||||
type AnyDeclarativeTool,
|
||||
ToolConfirmationOutcome,
|
||||
} from '../tools/tools.js';
|
||||
import {
|
||||
DiscoveredMCPTool,
|
||||
isMcpToolName,
|
||||
@@ -46,6 +49,9 @@ import {
|
||||
DEFAULT_QUERY_STRING,
|
||||
DEFAULT_MAX_TURNS,
|
||||
DEFAULT_MAX_TIME_MINUTES,
|
||||
SubagentActivityErrorType,
|
||||
SUBAGENT_REJECTED_ERROR_PREFIX,
|
||||
SUBAGENT_CANCELLED_ERROR_MESSAGE,
|
||||
type LocalAgentDefinition,
|
||||
type AgentInputs,
|
||||
type OutputObject,
|
||||
@@ -338,6 +344,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
this.emitActivity('ERROR', {
|
||||
error: `Agent stopped calling tools but did not call '${TASK_COMPLETE_TOOL_NAME}' to finalize the session.`,
|
||||
context: 'protocol_violation',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
return {
|
||||
status: 'stop',
|
||||
@@ -471,6 +478,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
this.emitActivity('ERROR', {
|
||||
error: `Graceful recovery attempt failed. Reason: ${turnResult.status}`,
|
||||
context: 'recovery_turn',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
return null;
|
||||
} catch (error) {
|
||||
@@ -478,6 +486,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
this.emitActivity('ERROR', {
|
||||
error: `Graceful recovery attempt failed: ${String(error)}`,
|
||||
context: 'recovery_turn',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
@@ -683,12 +692,14 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
this.emitActivity('ERROR', {
|
||||
error: finalResult,
|
||||
context: 'timeout',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
} else if (terminateReason === AgentTerminateMode.MAX_TURNS) {
|
||||
finalResult = `Agent reached max turns limit (${maxTurns}).`;
|
||||
this.emitActivity('ERROR', {
|
||||
error: finalResult,
|
||||
context: 'max_turns',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
} else if (
|
||||
terminateReason === AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL
|
||||
@@ -700,6 +711,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
this.emitActivity('ERROR', {
|
||||
error: finalResult,
|
||||
context: 'protocol_violation',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -754,6 +766,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
this.emitActivity('ERROR', {
|
||||
error: finalResult,
|
||||
context: 'timeout',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
return {
|
||||
result: finalResult,
|
||||
@@ -761,7 +774,10 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
};
|
||||
}
|
||||
|
||||
this.emitActivity('ERROR', { error: String(error) });
|
||||
this.emitActivity('ERROR', {
|
||||
error: String(error),
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
throw error; // Re-throw other errors or external aborts.
|
||||
} finally {
|
||||
deadlineTimer.abort();
|
||||
@@ -1030,6 +1046,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
context: 'tool_call',
|
||||
name: toolName,
|
||||
error,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -1057,6 +1074,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
context: 'tool_call',
|
||||
name: toolName,
|
||||
error,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -1099,6 +1117,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
name: toolName,
|
||||
callId,
|
||||
error,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -1142,6 +1161,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
name: toolName,
|
||||
callId,
|
||||
error,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1166,6 +1186,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
name: toolName,
|
||||
callId,
|
||||
error,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
|
||||
continue;
|
||||
@@ -1213,18 +1234,46 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
name: toolName,
|
||||
callId: call.request.callId,
|
||||
error: call.response.error?.message || 'Unknown error',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
} else if (call.status === 'cancelled') {
|
||||
this.emitActivity('ERROR', {
|
||||
context: 'tool_call',
|
||||
name: toolName,
|
||||
callId: call.request.callId,
|
||||
error: 'Request cancelled.',
|
||||
});
|
||||
aborted = true;
|
||||
const isSoftRejection =
|
||||
call.outcome === ToolConfirmationOutcome.Cancel;
|
||||
|
||||
if (isSoftRejection) {
|
||||
const error = `${SUBAGENT_REJECTED_ERROR_PREFIX} Please acknowledge this, rethink your strategy, and try a different approach. If you cannot proceed without the rejected operation, summarize the issue and use \`${TASK_COMPLETE_TOOL_NAME}\` to report your findings and the blocker.`;
|
||||
this.emitActivity('ERROR', {
|
||||
context: 'tool_call',
|
||||
name: toolName,
|
||||
callId: call.request.callId,
|
||||
error,
|
||||
errorType: SubagentActivityErrorType.REJECTED,
|
||||
});
|
||||
// Soft rejection: we do NOT set aborted=true, allowing the agent to rethink.
|
||||
|
||||
// Provide the direct instruction to the model as the tool error response.
|
||||
syncResults.set(call.request.callId, {
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: call.request.callId,
|
||||
response: { error },
|
||||
},
|
||||
});
|
||||
continue; // Skip the generic syncResults.set below
|
||||
} else {
|
||||
// Hard abort (Ctrl+C)
|
||||
this.emitActivity('ERROR', {
|
||||
context: 'tool_call',
|
||||
name: toolName,
|
||||
callId: call.request.callId,
|
||||
error: SUBAGENT_CANCELLED_ERROR_MESSAGE,
|
||||
errorType: SubagentActivityErrorType.CANCELLED,
|
||||
});
|
||||
aborted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Add result to syncResults to preserve order later
|
||||
// Add result to syncResults for other statuses (success, error, hard abort)
|
||||
syncResults.set(call.request.callId, call.response.responseParts[0]);
|
||||
}
|
||||
}
|
||||
@@ -1335,7 +1384,8 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
Important Rules:
|
||||
* You are running in a non-interactive mode. You CANNOT ask the user for input or clarification.
|
||||
* Work systematically using available tools to complete your task.
|
||||
* Always use absolute paths for file operations. Construct them using the provided "Environment Context".`;
|
||||
* Always use absolute paths for file operations. Construct them using the provided "Environment Context".
|
||||
* If a tool call is rejected by the user, acknowledge the rejection, rethink your strategy, and try a different approach. Do not repeatedly attempt the same rejected operation.`;
|
||||
|
||||
if (this.definition.outputConfig) {
|
||||
finalPrompt += `
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
type SubagentActivityEvent,
|
||||
type AgentInputs,
|
||||
type SubagentProgress,
|
||||
SubagentActivityErrorType,
|
||||
SUBAGENT_REJECTED_ERROR_PREFIX,
|
||||
} from './types.js';
|
||||
import { LocalSubagentInvocation } from './local-invocation.js';
|
||||
import { LocalAgentExecutor } from './local-executor.js';
|
||||
@@ -303,6 +305,48 @@ describe('LocalSubagentInvocation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should reflect tool rejections in the activity stream as cancelled but not abort the agent', async () => {
|
||||
mockExecutorInstance.run.mockImplementation(async () => {
|
||||
const onActivity = MockLocalAgentExecutor.create.mock.calls[0][2];
|
||||
|
||||
if (onActivity) {
|
||||
onActivity({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: { name: 'ls', args: {}, callId: 'call1' },
|
||||
} as SubagentActivityEvent);
|
||||
onActivity({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'ERROR',
|
||||
data: {
|
||||
name: 'ls',
|
||||
callId: 'call1',
|
||||
error: `${SUBAGENT_REJECTED_ERROR_PREFIX} Please acknowledge this, rethink your strategy, and try a different approach. If you cannot proceed without the rejected operation, summarize the issue and use \`complete_task\` to report your findings and the blocker.`,
|
||||
errorType: SubagentActivityErrorType.REJECTED,
|
||||
},
|
||||
} as SubagentActivityEvent);
|
||||
}
|
||||
return {
|
||||
result: 'Rethinking...',
|
||||
terminate_reason: AgentTerminateMode.GOAL,
|
||||
};
|
||||
});
|
||||
|
||||
await invocation.execute(signal, updateOutput);
|
||||
|
||||
expect(updateOutput).toHaveBeenCalledTimes(4);
|
||||
const lastCall = updateOutput.mock.calls[3][0] as SubagentProgress;
|
||||
expect(lastCall.recentActivity).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'tool_call',
|
||||
content: 'ls',
|
||||
status: 'cancelled',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should run successfully without an updateOutput callback', async () => {
|
||||
mockExecutorInstance.run.mockImplementation(async () => {
|
||||
const onActivity = MockLocalAgentExecutor.create.mock.calls[0][2];
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
type SubagentProgress,
|
||||
type SubagentActivityItem,
|
||||
AgentTerminateMode,
|
||||
SubagentActivityErrorType,
|
||||
SUBAGENT_REJECTED_ERROR_PREFIX,
|
||||
SUBAGENT_CANCELLED_ERROR_MESSAGE,
|
||||
} from './types.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
@@ -171,12 +174,19 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
}
|
||||
case 'ERROR': {
|
||||
const error = String(activity.data['error']);
|
||||
const isCancellation = error === 'Request cancelled.';
|
||||
const errorType = activity.data['errorType'];
|
||||
const isCancellation =
|
||||
errorType === SubagentActivityErrorType.CANCELLED ||
|
||||
error === SUBAGENT_CANCELLED_ERROR_MESSAGE;
|
||||
const isRejection =
|
||||
errorType === SubagentActivityErrorType.REJECTED ||
|
||||
error.startsWith(SUBAGENT_REJECTED_ERROR_PREFIX);
|
||||
|
||||
const toolName = activity.data['name']
|
||||
? String(activity.data['name'])
|
||||
: undefined;
|
||||
|
||||
if (toolName && isCancellation) {
|
||||
if (toolName && (isCancellation || isRejection)) {
|
||||
for (let i = recentActivity.length - 1; i >= 0; i--) {
|
||||
if (
|
||||
recentActivity[i].type === 'tool_call' &&
|
||||
@@ -188,13 +198,27 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (toolName) {
|
||||
// Mark non-rejection/non-cancellation errors as 'error'
|
||||
for (let i = recentActivity.length - 1; i >= 0; i--) {
|
||||
if (
|
||||
recentActivity[i].type === 'tool_call' &&
|
||||
recentActivity[i].content === toolName &&
|
||||
recentActivity[i].status === 'running'
|
||||
) {
|
||||
recentActivity[i].status = 'error';
|
||||
updated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recentActivity.push({
|
||||
id: randomUUID(),
|
||||
type: 'thought', // Treat errors as thoughts for now, or add an error type
|
||||
content: isCancellation ? error : `Error: ${error}`,
|
||||
status: isCancellation ? 'cancelled' : 'error',
|
||||
type: 'thought',
|
||||
content:
|
||||
isCancellation || isRejection ? error : `Error: ${error}`,
|
||||
status: isCancellation || isRejection ? 'cancelled' : 'error',
|
||||
});
|
||||
updated = true;
|
||||
break;
|
||||
|
||||
@@ -65,6 +65,18 @@ export type RemoteAgentInputs = { query: string };
|
||||
/**
|
||||
* Structured events emitted during subagent execution for user observability.
|
||||
*/
|
||||
export enum SubagentActivityErrorType {
|
||||
REJECTED = 'REJECTED',
|
||||
CANCELLED = 'CANCELLED',
|
||||
GENERIC = 'GENERIC',
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard error messages for subagent activities.
|
||||
*/
|
||||
export const SUBAGENT_REJECTED_ERROR_PREFIX = 'User rejected this operation.';
|
||||
export const SUBAGENT_CANCELLED_ERROR_MESSAGE = 'Request cancelled.';
|
||||
|
||||
export interface SubagentActivityEvent {
|
||||
isSubagentActivityEvent: true;
|
||||
agentName: string;
|
||||
|
||||
@@ -649,6 +649,8 @@ export interface ConfigParameters {
|
||||
billing?: {
|
||||
overageStrategy?: OverageStrategy;
|
||||
};
|
||||
simulateUser?: boolean;
|
||||
knowledgeSources?: string[];
|
||||
}
|
||||
|
||||
export class Config implements McpContext, AgentLoopContext {
|
||||
@@ -866,6 +868,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private lastModeSwitchTime: number = performance.now();
|
||||
readonly injectionService: InjectionService;
|
||||
private approvedPlanPath: string | undefined;
|
||||
private readonly simulateUser: boolean;
|
||||
private readonly knowledgeSources: string[];
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
this._sessionId = params.sessionId;
|
||||
@@ -1095,6 +1099,8 @@ 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.knowledgeSources = params.knowledgeSources ?? [];
|
||||
|
||||
// Initialize Safety Infrastructure
|
||||
const contextBuilder = new ContextBuilder(this);
|
||||
@@ -2509,6 +2515,14 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.usageStatisticsEnabled;
|
||||
}
|
||||
|
||||
getSimulateUser(): boolean {
|
||||
return this.simulateUser;
|
||||
}
|
||||
|
||||
getKnowledgeSources(): string[] {
|
||||
return this.knowledgeSources;
|
||||
}
|
||||
|
||||
getAcpMode(): boolean {
|
||||
return this.acpMode;
|
||||
}
|
||||
|
||||
@@ -277,7 +277,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',
|
||||
|
||||
@@ -725,6 +725,26 @@ describe('createContentGeneratorConfig', () => {
|
||||
expect(config.apiKey).toBeUndefined();
|
||||
expect(config.vertexai).toBeUndefined();
|
||||
});
|
||||
it('should configure for GATEWAY using dummy placeholder if GEMINI_API_KEY is set', async () => {
|
||||
vi.stubEnv('GEMINI_API_KEY', 'env-gemini-key');
|
||||
const config = await createContentGeneratorConfig(
|
||||
mockConfig,
|
||||
AuthType.GATEWAY,
|
||||
);
|
||||
expect(config.apiKey).toBe('gateway-placeholder-key');
|
||||
expect(config.vertexai).toBe(false);
|
||||
});
|
||||
|
||||
it('should configure for GATEWAY using dummy placeholder if GEMINI_API_KEY is not set', async () => {
|
||||
vi.stubEnv('GEMINI_API_KEY', '');
|
||||
vi.mocked(loadApiKey).mockResolvedValue(null);
|
||||
const config = await createContentGeneratorConfig(
|
||||
mockConfig,
|
||||
AuthType.GATEWAY,
|
||||
);
|
||||
expect(config.apiKey).toBe('gateway-placeholder-key');
|
||||
expect(config.vertexai).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBaseUrl', () => {
|
||||
|
||||
@@ -150,6 +150,13 @@ export async function createContentGeneratorConfig(
|
||||
return contentGeneratorConfig;
|
||||
}
|
||||
|
||||
if (authType === AuthType.GATEWAY) {
|
||||
contentGeneratorConfig.apiKey = apiKey || 'gateway-placeholder-key';
|
||||
contentGeneratorConfig.vertexai = false;
|
||||
|
||||
return contentGeneratorConfig;
|
||||
}
|
||||
|
||||
return contentGeneratorConfig;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,4 +16,5 @@ export enum LlmRole {
|
||||
UTILITY_EDIT_CORRECTOR = 'utility_edit_corrector',
|
||||
UTILITY_AUTOCOMPLETE = 'utility_autocomplete',
|
||||
UTILITY_FAST_ACK_HELPER = 'utility_fast_ack_helper',
|
||||
UTILITY_SIMULATOR = 'utility_simulator',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# User Simulation Feature - Design Document
|
||||
|
||||
*Branch: `user-simulation`*
|
||||
|
||||
## 1. Overview
|
||||
The User Simulation feature enables an autonomous LLM-driven agent to act as a realistic user in interactive terminal sessions. It aims to support integration testing, evaluation, and background evaluation of system capabilities by effectively observing the terminal interface and supplying contextual standard input (stdin).
|
||||
|
||||
This is accomplished by adding a `--simulate-user` option to the CLI, intercepting terminal updates via a simulated stdout reader, computing appropriate responses via an LLM, and piping these directly into a `PassThrough` stream masquerading as `process.stdin`.
|
||||
|
||||
## 2. Core Architecture
|
||||
|
||||
### 2.1 Configurations and Triggers
|
||||
**Affected Files:**
|
||||
- `packages/cli/src/config/config.ts`
|
||||
- `packages/core/src/config/config.ts`
|
||||
- `packages/cli/src/interactiveCli.tsx`
|
||||
|
||||
**Implementation:**
|
||||
The feature is controlled via the explicit CLI argument `--simulate-user`. When supplied, the argument flows through the `Config` interface (`config.getSimulateUser()`), modifying how the interactive CLI bootstraps.
|
||||
- Instead of attaching the raw `process.stdin` directly to the `ink` render environment, the UI engine binds to a Node.js `PassThrough` stream if simulation is enabled.
|
||||
- The Interactive CLI session begins by initiating the `UserSimulator`, providing it access to the `Config`, the `simulatedStdin` buffer, and an observable snapshot of the `lastFrame` (the current state of the UI render).
|
||||
|
||||
### 2.2 The `UserSimulator` Service
|
||||
**Affected Files:**
|
||||
- `packages/cli/src/services/UserSimulator.ts`
|
||||
|
||||
**Implementation:**
|
||||
The `UserSimulator` class governs the core observation and input loops. It handles interactions asynchronously outside of the primary command execution flow.
|
||||
|
||||
#### Loop Execution
|
||||
The simulator runs via a repeating timer (3-second intervals `setInterval`), checking `tick()`. It ensures only one evaluation happens sequentially using a mutex lock (`this.isProcessing`).
|
||||
|
||||
#### Screen Processing and Environment Context
|
||||
The engine receives the rendered terminal snapshot (`lastFrame`) and applies sanitizations:
|
||||
- Removes ANSI terminal colors and cursor properties using regex filtering.
|
||||
- Strips variable UI animation symbols (Spinners like ⠋, ⠙) and time indicators (e.g., `[ 7s ]`).
|
||||
These visual stability checks allow the simulator to detect meaningful frame changes instead of being repeatedly triggered by minor clock updates.
|
||||
|
||||
#### Simulation Brain & LLM Integration
|
||||
The simulator utilizes `PREVIEW_GEMINI_FLASH_MODEL`, leveraging the newly configured `thinking: true` capacity (`packages/core/src/config/defaultModelConfigs.ts`) for better reasoning, operating under the distinct telemetry role `LlmRole.UTILITY_SIMULATOR`.
|
||||
|
||||
The LLM is provided an extensive context schema encapsulating:
|
||||
- **Terminal output:** Current stripped terminal representation.
|
||||
- **Original task objective:** The intention of the CLI session.
|
||||
- **Action Memory:** A tracked sequence (`actionHistory`) of previously executed actions to avoid getting stuck in inquiry loops.
|
||||
|
||||
#### State Machine Interpretation
|
||||
The model translates the screen context into specific behavioral states:
|
||||
- **STATE 1 (WAIT):** Triggered if any indication of internal "thinking" or processing exists (active spinners/timers). Returns `<WAIT>`.
|
||||
- **STATE 2 (ACTION/AUTHORIZATION):** Prompt requests an approval (e.g., "Allow execution"). Supplies strict raw choice input.
|
||||
- **STATE 3 (DONE):** Fully idle string detection paired with task completion fulfillment. Gracefully types `Thank you\r` and eventually exits indicating `<DONE>`.
|
||||
- **STATE 4 (INPUT):** Idle state where continuation text instruction is needed, supplying direct commands injected to stdin.
|
||||
|
||||
#### Safeguards and Debugging
|
||||
- **Anti-Stall Mechanism:** The simulator tracks repetitive actions. If the LLM repeats the exact same non-empty interaction `MAX_REPEATS` (3) times, it terminates the process abruptly to prevent infinite loops.
|
||||
- **Interaction Transcripts:** Binds an audit log, dumping each parsed `[SIMULATOR] Screen Content Seen` block, the constructed `Prompt Used`, and `Raw model response` to discrete contextual `interactions_<timestamp>.txt` files, making it traceable.
|
||||
|
||||
### 2.3 Required Ecosystem Changes
|
||||
- `packages/core/src/telemetry/llmRole.ts`: A new internal trace role `UTILITY_SIMULATOR` has been appended to separate evaluation events from main-thread generations.
|
||||
- `packages/core/src/config/defaultModelConfigs.ts`: Added dynamic thought logic parameter (`thinking: true`) for `gemini-3-flash-preview` to enhance the simulation depth reasoning.
|
||||
- Unit Testing Mocks: Updated dummy interface `mockConfig.ts` to ensure compatibility by safely mocking `getSimulateUser`.
|
||||
|
||||
## 3. Workflow Summary
|
||||
1. The user launches `gemini --simulate-user`.
|
||||
2. Interactive UI bindings swap `process.stdin` for a readable `PassThrough` stream.
|
||||
3. Every 3 seconds, `UserSimulator.tick()` accesses the newest render frame.
|
||||
4. The frame is stripped of unstable metrics/spinners. If the clean frame is unchanged since the last tick, it waits.
|
||||
5. If changed, the simulator invokes Gemini Flash Preview (Thinking).
|
||||
6. Gemini observes the state—emitting `<WAIT>` if busy, action keys for selections, `<DONE>` if completed, or commands to be resolved.
|
||||
7. Return text is piped cleanly back to `simulatedStdin`—advancing the ink render state.
|
||||
|
||||
*This design effectively encapsulates the user interface flow without imposing side-effects on standard operations.*
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user