mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9dcdf9b2e8 | |||
| 8db2948361 | |||
| 5fa14dbe42 | |||
| e6cd5d208c | |||
| c9d48026c4 | |||
| 34f271504a | |||
| d68100e6bc | |||
| 94e6bf8591 | |||
| fd44718bfe | |||
| f6e21f50fd | |||
| 1725ec346b | |||
| c12fc340c1 | |||
| b6d5374fb7 | |||
| 0082e1ec97 | |||
| 0ed9f1e7f5 | |||
| adf21df71e | |||
| 4dcca1ca10 |
@@ -352,21 +352,6 @@ npm run lint
|
||||
- **Imports:** Pay special attention to import paths. The project uses ESLint to
|
||||
enforce restrictions on relative imports between packages.
|
||||
|
||||
### Project structure
|
||||
|
||||
- `packages/`: Contains the individual sub-packages of the project.
|
||||
- `a2a-server`: A2A server implementation for the Gemini CLI. (Experimental)
|
||||
- `cli/`: The command-line interface.
|
||||
- `core/`: The core backend logic for the Gemini CLI.
|
||||
- `test-utils` Utilities for creating and cleaning temporary file systems for
|
||||
testing.
|
||||
- `vscode-ide-companion/`: The Gemini CLI Companion extension pairs with
|
||||
Gemini CLI.
|
||||
- `docs/`: Contains all project documentation.
|
||||
- `scripts/`: Utility scripts for building, testing, and development tasks.
|
||||
|
||||
For more detailed architecture, see `docs/architecture.md`.
|
||||
|
||||
### Debugging
|
||||
|
||||
#### VS Code
|
||||
|
||||
@@ -314,7 +314,6 @@ gemini
|
||||
|
||||
- [**Headless Mode (Scripting)**](./docs/cli/headless.md) - Use Gemini CLI in
|
||||
automated workflows.
|
||||
- [**Architecture Overview**](./docs/architecture.md) - How Gemini CLI works.
|
||||
- [**IDE Integration**](./docs/ide-integration/index.md) - VS Code companion.
|
||||
- [**Sandboxing & Security**](./docs/cli/sandbox.md) - Safe execution
|
||||
environments.
|
||||
|
||||
+354
-461
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.34.0-preview.4
|
||||
# Preview release: v0.35.0-preview.1
|
||||
|
||||
Released: March 16, 2026
|
||||
Released: March 17, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -13,471 +13,364 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Plan Mode Enabled by Default:** Plan Mode is now enabled out-of-the-box,
|
||||
providing a structured planning workflow and keeping approved plans during
|
||||
chat compression.
|
||||
- **Sandboxing Enhancements:** Added experimental LXC container sandbox support
|
||||
and native gVisor (`runsc`) sandboxing for improved security and isolation.
|
||||
- **Tracker Visualization and Tools:** Introduced CRUD tools and visualization
|
||||
for trackers, along with task tracker strategy improvements.
|
||||
- **Browser Agent Improvements:** Enhanced the browser agent with progress
|
||||
emission, a new automation overlay, and additional integration tests.
|
||||
- **CLI and UI Updates:** Standardized semantic focus colors, polished shell
|
||||
autocomplete rendering, unified keybinding infrastructure, and added custom
|
||||
footer configuration options.
|
||||
- **Subagents & Architecture Enhancements**: Enabled subagents and laid the
|
||||
foundation for subagent tool isolation. Added proxy routing support for remote
|
||||
A2A subagents and integrated `SandboxManager` to sandbox all process-spawning
|
||||
tools.
|
||||
- **CLI & UI Improvements**: Introduced customizable keyboard shortcuts and
|
||||
support for literal character keybindings. Added missing vim mode motions and
|
||||
CJK input support. Enabled code splitting and deferred UI loading for improved
|
||||
performance.
|
||||
- **Context & Tools Optimization**: JIT context loading is now enabled by
|
||||
default with deduplication for project memory. Introduced a model-driven
|
||||
parallel tool scheduler and allowed safe tools to execute concurrently.
|
||||
- **Security & Extensions**: Implemented cryptographic integrity verification
|
||||
for extension updates and added a `disableAlwaysAllow` setting to prevent
|
||||
auto-approvals for enhanced security.
|
||||
- **Plan Mode & Web Fetch Updates**: Added an 'All the above' option for
|
||||
multi-select AskUser questions in Plan Mode. Rolled out Stage 1 and Stage 2
|
||||
security and consistency improvements for the `web_fetch` tool.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 48130eb to release/v0.34.0-preview.3-pr-22665 to patch
|
||||
version v0.34.0-preview.3 and create version 0.34.0-preview.4 by
|
||||
- feat(cli): customizable keyboard shortcuts by @scidomino in
|
||||
[#21945](https://github.com/google-gemini/gemini-cli/pull/21945)
|
||||
- feat(core): Thread `AgentLoopContext` through core. by @joshualitt in
|
||||
[#21944](https://github.com/google-gemini/gemini-cli/pull/21944)
|
||||
- chore(release): bump version to 0.35.0-nightly.20260311.657f19c1f by
|
||||
@gemini-cli-robot in
|
||||
[#22719](https://github.com/google-gemini/gemini-cli/pull/22719)
|
||||
- fix(patch): cherry-pick 24adacd to release/v0.34.0-preview.2-pr-22332 to patch
|
||||
version v0.34.0-preview.2 and create version 0.34.0-preview.3 by
|
||||
@gemini-cli-robot in
|
||||
[#22391](https://github.com/google-gemini/gemini-cli/pull/22391)
|
||||
- fix(patch): cherry-pick 8432bce to release/v0.34.0-preview.1-pr-22069 to patch
|
||||
version v0.34.0-preview.1 and create version 0.34.0-preview.2 by
|
||||
@gemini-cli-robot in
|
||||
[#22205](https://github.com/google-gemini/gemini-cli/pull/22205)
|
||||
- fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148
|
||||
[CONFLICTS] by @gemini-cli-robot in
|
||||
[#22174](https://github.com/google-gemini/gemini-cli/pull/22174)
|
||||
- feat(cli): add chat resume footer on session quit by @lordshashank in
|
||||
[#20667](https://github.com/google-gemini/gemini-cli/pull/20667)
|
||||
- Support bold and other styles in svg snapshots by @jacob314 in
|
||||
[#20937](https://github.com/google-gemini/gemini-cli/pull/20937)
|
||||
- fix(core): increase A2A agent timeout to 30 minutes by @adamfweidman in
|
||||
[#21028](https://github.com/google-gemini/gemini-cli/pull/21028)
|
||||
- Cleanup old branches. by @jacob314 in
|
||||
[#19354](https://github.com/google-gemini/gemini-cli/pull/19354)
|
||||
- chore(release): bump version to 0.34.0-nightly.20260303.34f0c1538 by
|
||||
@gemini-cli-robot in
|
||||
[#21034](https://github.com/google-gemini/gemini-cli/pull/21034)
|
||||
- feat(ui): standardize semantic focus colors and enhance history visibility by
|
||||
@keithguerin in
|
||||
[#20745](https://github.com/google-gemini/gemini-cli/pull/20745)
|
||||
- fix: merge duplicate imports in packages/core (3/4) by @Nixxx19 in
|
||||
[#20928](https://github.com/google-gemini/gemini-cli/pull/20928)
|
||||
- Add extra safety checks for proto pollution by @jacob314 in
|
||||
[#20396](https://github.com/google-gemini/gemini-cli/pull/20396)
|
||||
- feat(core): Add tracker CRUD tools & visualization by @anj-s in
|
||||
[#19489](https://github.com/google-gemini/gemini-cli/pull/19489)
|
||||
- Revert "fix(ui): persist expansion in AskUser dialog when navigating options"
|
||||
by @jacob314 in
|
||||
[#21042](https://github.com/google-gemini/gemini-cli/pull/21042)
|
||||
- Changelog for v0.33.0-preview.0 by @gemini-cli-robot in
|
||||
[#21030](https://github.com/google-gemini/gemini-cli/pull/21030)
|
||||
- fix: model persistence for all scenarios by @sripasg in
|
||||
[#21051](https://github.com/google-gemini/gemini-cli/pull/21051)
|
||||
- chore/release: bump version to 0.34.0-nightly.20260304.28af4e127 by
|
||||
@gemini-cli-robot in
|
||||
[#21054](https://github.com/google-gemini/gemini-cli/pull/21054)
|
||||
- Consistently guard restarts against concurrent auto updates by @scidomino in
|
||||
[#21016](https://github.com/google-gemini/gemini-cli/pull/21016)
|
||||
- Defensive coding to reduce the risk of Maximum update depth errors by
|
||||
@jacob314 in [#20940](https://github.com/google-gemini/gemini-cli/pull/20940)
|
||||
- fix(cli): Polish shell autocomplete rendering to be a little more shell native
|
||||
feeling. by @jacob314 in
|
||||
[#20931](https://github.com/google-gemini/gemini-cli/pull/20931)
|
||||
- Docs: Update plan mode docs by @jkcinouye in
|
||||
[#19682](https://github.com/google-gemini/gemini-cli/pull/19682)
|
||||
- fix(mcp): Notifications/tools/list_changed support not working by @jacob314 in
|
||||
[#21050](https://github.com/google-gemini/gemini-cli/pull/21050)
|
||||
- fix(cli): register extension lifecycle events in DebugProfiler by
|
||||
@fayerman-source in
|
||||
[#20101](https://github.com/google-gemini/gemini-cli/pull/20101)
|
||||
- chore(dev): update vscode settings for typescriptreact by @rohit-4321 in
|
||||
[#19907](https://github.com/google-gemini/gemini-cli/pull/19907)
|
||||
- fix(cli): enable multi-arch docker builds for sandbox by @ru-aish in
|
||||
[#19821](https://github.com/google-gemini/gemini-cli/pull/19821)
|
||||
- Changelog for v0.32.0 by @gemini-cli-robot in
|
||||
[#21033](https://github.com/google-gemini/gemini-cli/pull/21033)
|
||||
- Changelog for v0.33.0-preview.1 by @gemini-cli-robot in
|
||||
[#21058](https://github.com/google-gemini/gemini-cli/pull/21058)
|
||||
- feat(core): improve @scripts/copy_files.js autocomplete to prioritize
|
||||
filenames by @sehoon38 in
|
||||
[#21064](https://github.com/google-gemini/gemini-cli/pull/21064)
|
||||
- feat(sandbox): add experimental LXC container sandbox support by @h30s in
|
||||
[#20735](https://github.com/google-gemini/gemini-cli/pull/20735)
|
||||
- feat(evals): add overall pass rate row to eval nightly summary table by
|
||||
@gundermanc in
|
||||
[#20905](https://github.com/google-gemini/gemini-cli/pull/20905)
|
||||
- feat(telemetry): include language in telemetry and fix accepted lines
|
||||
computation by @gundermanc in
|
||||
[#21126](https://github.com/google-gemini/gemini-cli/pull/21126)
|
||||
- Changelog for v0.32.1 by @gemini-cli-robot in
|
||||
[#21055](https://github.com/google-gemini/gemini-cli/pull/21055)
|
||||
- feat(core): add robustness tests, logging, and metrics for CodeAssistServer
|
||||
SSE parsing by @yunaseoul in
|
||||
[#21013](https://github.com/google-gemini/gemini-cli/pull/21013)
|
||||
- feat: add issue assignee workflow by @kartikangiras in
|
||||
[#21003](https://github.com/google-gemini/gemini-cli/pull/21003)
|
||||
- fix: improve error message when OAuth succeeds but project ID is required by
|
||||
@Nixxx19 in [#21070](https://github.com/google-gemini/gemini-cli/pull/21070)
|
||||
- feat(loop-reduction): implement iterative loop detection and model feedback by
|
||||
@aishaneeshah in
|
||||
[#20763](https://github.com/google-gemini/gemini-cli/pull/20763)
|
||||
- chore(github): require prompt approvers for agent prompt files by @gundermanc
|
||||
in [#20896](https://github.com/google-gemini/gemini-cli/pull/20896)
|
||||
- Docs: Create tools reference by @jkcinouye in
|
||||
[#19470](https://github.com/google-gemini/gemini-cli/pull/19470)
|
||||
- fix(core, a2a-server): prevent hang during OAuth in non-interactive sessions
|
||||
by @spencer426 in
|
||||
[#21045](https://github.com/google-gemini/gemini-cli/pull/21045)
|
||||
- chore(cli): enable deprecated settings removal by default by @yashodipmore in
|
||||
[#20682](https://github.com/google-gemini/gemini-cli/pull/20682)
|
||||
- feat(core): Disable fast ack helper for hints. by @joshualitt in
|
||||
[#21011](https://github.com/google-gemini/gemini-cli/pull/21011)
|
||||
- fix(ui): suppress redundant failure note when tool error note is shown by
|
||||
@NTaylorMullen in
|
||||
[#21078](https://github.com/google-gemini/gemini-cli/pull/21078)
|
||||
- docs: document planning workflows with Conductor example by @jerop in
|
||||
[#21166](https://github.com/google-gemini/gemini-cli/pull/21166)
|
||||
- feat(release): ship esbuild bundle in npm package by @genneth in
|
||||
[#19171](https://github.com/google-gemini/gemini-cli/pull/19171)
|
||||
- fix(extensions): preserve symlinks in extension source path while enforcing
|
||||
folder trust by @galz10 in
|
||||
[#20867](https://github.com/google-gemini/gemini-cli/pull/20867)
|
||||
- fix(cli): defer tool exclusions to policy engine in non-interactive mode by
|
||||
@EricRahm in [#20639](https://github.com/google-gemini/gemini-cli/pull/20639)
|
||||
- fix(ui): removed double padding on rendered content by @devr0306 in
|
||||
[#21029](https://github.com/google-gemini/gemini-cli/pull/21029)
|
||||
- fix(core): truncate excessively long lines in grep search output by
|
||||
@gundermanc in
|
||||
[#21147](https://github.com/google-gemini/gemini-cli/pull/21147)
|
||||
- feat: add custom footer configuration via `/footer` by @jackwotherspoon in
|
||||
[#19001](https://github.com/google-gemini/gemini-cli/pull/19001)
|
||||
- perf(core): fix OOM crash in long-running sessions by @WizardsForgeGames in
|
||||
[#19608](https://github.com/google-gemini/gemini-cli/pull/19608)
|
||||
- refactor(cli): categorize built-in themes into dark/ and light/ directories by
|
||||
@JayadityaGit in
|
||||
[#18634](https://github.com/google-gemini/gemini-cli/pull/18634)
|
||||
- fix(core): explicitly allow codebase_investigator and cli_help in read-only
|
||||
mode by @Adib234 in
|
||||
[#21157](https://github.com/google-gemini/gemini-cli/pull/21157)
|
||||
- test: add browser agent integration tests by @kunal-10-cloud in
|
||||
[#21151](https://github.com/google-gemini/gemini-cli/pull/21151)
|
||||
- fix(cli): fix enabling kitty codes on Windows Terminal by @scidomino in
|
||||
[#21136](https://github.com/google-gemini/gemini-cli/pull/21136)
|
||||
- refactor(core): extract shared OAuth flow primitives from MCPOAuthProvider by
|
||||
@SandyTao520 in
|
||||
[#20895](https://github.com/google-gemini/gemini-cli/pull/20895)
|
||||
- fix(ui): add partial output to cancelled shell UI by @devr0306 in
|
||||
[#21178](https://github.com/google-gemini/gemini-cli/pull/21178)
|
||||
- fix(cli): replace hardcoded keybinding strings with dynamic formatters by
|
||||
@scidomino in [#21159](https://github.com/google-gemini/gemini-cli/pull/21159)
|
||||
- DOCS: Update quota and pricing page by @g-samroberts in
|
||||
[#21194](https://github.com/google-gemini/gemini-cli/pull/21194)
|
||||
- feat(telemetry): implement Clearcut logging for startup statistics by
|
||||
@yunaseoul in [#21172](https://github.com/google-gemini/gemini-cli/pull/21172)
|
||||
- feat(triage): add area/documentation to issue triage by @g-samroberts in
|
||||
[#21222](https://github.com/google-gemini/gemini-cli/pull/21222)
|
||||
- Fix so shell calls are formatted by @jacob314 in
|
||||
[#21237](https://github.com/google-gemini/gemini-cli/pull/21237)
|
||||
- feat(cli): add native gVisor (runsc) sandboxing support by @Zheyuan-Lin in
|
||||
[#21062](https://github.com/google-gemini/gemini-cli/pull/21062)
|
||||
- docs: use absolute paths for internal links in plan-mode.md by @jerop in
|
||||
[#21299](https://github.com/google-gemini/gemini-cli/pull/21299)
|
||||
- fix(core): prevent unhandled AbortError crash during stream loop detection by
|
||||
@7hokerz in [#21123](https://github.com/google-gemini/gemini-cli/pull/21123)
|
||||
- fix:reorder env var redaction checks to scan values first by @kartikangiras in
|
||||
[#21059](https://github.com/google-gemini/gemini-cli/pull/21059)
|
||||
- fix(acp): rename --experimental-acp to --acp & remove Zed-specific refrences
|
||||
by @skeshive in
|
||||
[#21171](https://github.com/google-gemini/gemini-cli/pull/21171)
|
||||
- feat(core): fallback to 2.5 models with no access for toolcalls by @sehoon38
|
||||
in [#21283](https://github.com/google-gemini/gemini-cli/pull/21283)
|
||||
- test(core): improve testing for API request/response parsing by @sehoon38 in
|
||||
[#21227](https://github.com/google-gemini/gemini-cli/pull/21227)
|
||||
- docs(links): update docs-writer skill and fix broken link by @g-samroberts in
|
||||
[#21314](https://github.com/google-gemini/gemini-cli/pull/21314)
|
||||
- Fix code colorizer ansi escape bug. by @jacob314 in
|
||||
[#21321](https://github.com/google-gemini/gemini-cli/pull/21321)
|
||||
- remove wildcard behavior on keybindings by @scidomino in
|
||||
[#21315](https://github.com/google-gemini/gemini-cli/pull/21315)
|
||||
- feat(acp): Add support for AI Gateway auth by @skeshive in
|
||||
[#21305](https://github.com/google-gemini/gemini-cli/pull/21305)
|
||||
- fix(theme): improve theme color contrast for macOS Terminal.app by @clocky in
|
||||
[#21175](https://github.com/google-gemini/gemini-cli/pull/21175)
|
||||
- feat (core): Implement tracker related SI changes by @anj-s in
|
||||
[#19964](https://github.com/google-gemini/gemini-cli/pull/19964)
|
||||
- Changelog for v0.33.0-preview.2 by @gemini-cli-robot in
|
||||
[#21333](https://github.com/google-gemini/gemini-cli/pull/21333)
|
||||
- Changelog for v0.33.0-preview.3 by @gemini-cli-robot in
|
||||
[#21347](https://github.com/google-gemini/gemini-cli/pull/21347)
|
||||
- docs: format release times as HH:MM UTC by @pavan-sh in
|
||||
[#20726](https://github.com/google-gemini/gemini-cli/pull/20726)
|
||||
- fix(cli): implement --all flag for extensions uninstall by @sehoon38 in
|
||||
[#21319](https://github.com/google-gemini/gemini-cli/pull/21319)
|
||||
- docs: fix incorrect relative links to command reference by @kanywst in
|
||||
[#20964](https://github.com/google-gemini/gemini-cli/pull/20964)
|
||||
- documentiong ensures ripgrep by @Jatin24062005 in
|
||||
[#21298](https://github.com/google-gemini/gemini-cli/pull/21298)
|
||||
- fix(core): handle AbortError thrown during processTurn by @MumuTW in
|
||||
[#21296](https://github.com/google-gemini/gemini-cli/pull/21296)
|
||||
- docs(cli): clarify ! command output visibility in shell commands tutorial by
|
||||
@MohammedADev in
|
||||
[#21041](https://github.com/google-gemini/gemini-cli/pull/21041)
|
||||
- fix: logic for task tracker strategy and remove tracker tools by @anj-s in
|
||||
[#21355](https://github.com/google-gemini/gemini-cli/pull/21355)
|
||||
- fix(partUtils): display media type and size for inline data parts by @Aboudjem
|
||||
in [#21358](https://github.com/google-gemini/gemini-cli/pull/21358)
|
||||
- Fix(accessibility): add screen reader support to RewindViewer by @Famous077 in
|
||||
[#20750](https://github.com/google-gemini/gemini-cli/pull/20750)
|
||||
- fix(hooks): propagate stopHookActive in AfterAgent retry path (#20426) by
|
||||
@Aarchi-07 in [#20439](https://github.com/google-gemini/gemini-cli/pull/20439)
|
||||
- fix(core): deduplicate GEMINI.md files by device/inode on case-insensitive
|
||||
filesystems (#19904) by @Nixxx19 in
|
||||
[#19915](https://github.com/google-gemini/gemini-cli/pull/19915)
|
||||
- feat(core): add concurrency safety guidance for subagent delegation (#17753)
|
||||
by @abhipatel12 in
|
||||
[#21278](https://github.com/google-gemini/gemini-cli/pull/21278)
|
||||
- feat(ui): dynamically generate all keybinding hints by @scidomino in
|
||||
[#21346](https://github.com/google-gemini/gemini-cli/pull/21346)
|
||||
- feat(core): implement unified KeychainService and migrate token storage by
|
||||
@ehedlund in [#21344](https://github.com/google-gemini/gemini-cli/pull/21344)
|
||||
- fix(cli): gracefully handle --resume when no sessions exist by @SandyTao520 in
|
||||
[#21429](https://github.com/google-gemini/gemini-cli/pull/21429)
|
||||
- fix(plan): keep approved plan during chat compression by @ruomengz in
|
||||
[#21284](https://github.com/google-gemini/gemini-cli/pull/21284)
|
||||
- feat(core): implement generic CacheService and optimize setupUser by @sehoon38
|
||||
in [#21374](https://github.com/google-gemini/gemini-cli/pull/21374)
|
||||
- Update quota and pricing documentation with subscription tiers by @srithreepo
|
||||
in [#21351](https://github.com/google-gemini/gemini-cli/pull/21351)
|
||||
- fix(core): append correct OTLP paths for HTTP exporters by
|
||||
@sebastien-prudhomme in
|
||||
[#16836](https://github.com/google-gemini/gemini-cli/pull/16836)
|
||||
- Changelog for v0.33.0-preview.4 by @gemini-cli-robot in
|
||||
[#21354](https://github.com/google-gemini/gemini-cli/pull/21354)
|
||||
- feat(cli): implement dot-prefixing for slash command conflicts by @ehedlund in
|
||||
[#20979](https://github.com/google-gemini/gemini-cli/pull/20979)
|
||||
- refactor(core): standardize MCP tool naming to mcp\_ FQN format by
|
||||
@abhipatel12 in
|
||||
[#21425](https://github.com/google-gemini/gemini-cli/pull/21425)
|
||||
- feat(cli): hide gemma settings from display and mark as experimental by
|
||||
@abhipatel12 in
|
||||
[#21471](https://github.com/google-gemini/gemini-cli/pull/21471)
|
||||
- feat(skills): refine string-reviewer guidelines and description by @clocky in
|
||||
[#20368](https://github.com/google-gemini/gemini-cli/pull/20368)
|
||||
- fix(core): whitelist TERM and COLORTERM in environment sanitization by
|
||||
@deadsmash07 in
|
||||
[#20514](https://github.com/google-gemini/gemini-cli/pull/20514)
|
||||
- fix(billing): fix overage strategy lifecycle and settings integration by
|
||||
@gsquared94 in
|
||||
[#21236](https://github.com/google-gemini/gemini-cli/pull/21236)
|
||||
- fix: expand paste placeholders in TextInput on submit by @Jefftree in
|
||||
[#19946](https://github.com/google-gemini/gemini-cli/pull/19946)
|
||||
- fix(core): add in-memory cache to ChatRecordingService to prevent OOM by
|
||||
@SandyTao520 in
|
||||
[#21502](https://github.com/google-gemini/gemini-cli/pull/21502)
|
||||
- feat(cli): overhaul thinking UI by @keithguerin in
|
||||
[#18725](https://github.com/google-gemini/gemini-cli/pull/18725)
|
||||
- fix(ui): unify Ctrl+O expansion hint experience across buffer modes by
|
||||
@jwhelangoog in
|
||||
[#21474](https://github.com/google-gemini/gemini-cli/pull/21474)
|
||||
- fix(cli): correct shell height reporting by @jacob314 in
|
||||
[#21492](https://github.com/google-gemini/gemini-cli/pull/21492)
|
||||
- Make test suite pass when the GEMINI_SYSTEM_MD env variable or
|
||||
GEMINI_WRITE_SYSTEM_MD variable happens to be set locally/ by @jacob314 in
|
||||
[#21480](https://github.com/google-gemini/gemini-cli/pull/21480)
|
||||
- Disallow underspecified types by @gundermanc in
|
||||
[#21485](https://github.com/google-gemini/gemini-cli/pull/21485)
|
||||
- refactor(cli): standardize on 'reload' verb for all components by @keithguerin
|
||||
in [#20654](https://github.com/google-gemini/gemini-cli/pull/20654)
|
||||
- feat(cli): Invert quota language to 'percent used' by @keithguerin in
|
||||
[#20100](https://github.com/google-gemini/gemini-cli/pull/20100)
|
||||
- Docs: Add documentation for notifications (experimental)(macOS) by @jkcinouye
|
||||
in [#21163](https://github.com/google-gemini/gemini-cli/pull/21163)
|
||||
- Code review comments as a pr by @jacob314 in
|
||||
[#21209](https://github.com/google-gemini/gemini-cli/pull/21209)
|
||||
- feat(cli): unify /chat and /resume command UX by @LyalinDotCom in
|
||||
[#20256](https://github.com/google-gemini/gemini-cli/pull/20256)
|
||||
- docs: fix typo 'allowslisted' -> 'allowlisted' in mcp-server.md by
|
||||
[#21966](https://github.com/google-gemini/gemini-cli/pull/21966)
|
||||
- refactor(a2a): remove legacy CoreToolScheduler by @adamfweidman in
|
||||
[#21955](https://github.com/google-gemini/gemini-cli/pull/21955)
|
||||
- feat(ui): add missing vim mode motions (X, ~, r, f/F/t/T, df/dt and friends)
|
||||
by @aanari in [#21932](https://github.com/google-gemini/gemini-cli/pull/21932)
|
||||
- Feat/retry fetch notifications by @aishaneeshah in
|
||||
[#21813](https://github.com/google-gemini/gemini-cli/pull/21813)
|
||||
- fix(core): remove OAuth check from handleFallback and clean up stray file by
|
||||
@sehoon38 in [#21962](https://github.com/google-gemini/gemini-cli/pull/21962)
|
||||
- feat(cli): support literal character keybindings and extended Kitty protocol
|
||||
keys by @scidomino in
|
||||
[#21972](https://github.com/google-gemini/gemini-cli/pull/21972)
|
||||
- fix(ui): clamp cursor to last char after all NORMAL mode deletes by @aanari in
|
||||
[#21973](https://github.com/google-gemini/gemini-cli/pull/21973)
|
||||
- test(core): add missing tests for prompts/utils.ts by @krrishverma1805-web in
|
||||
[#19941](https://github.com/google-gemini/gemini-cli/pull/19941)
|
||||
- fix(cli): allow scrolling keys in copy mode (Ctrl+S selection mode) by
|
||||
@nsalerni in [#19933](https://github.com/google-gemini/gemini-cli/pull/19933)
|
||||
- docs(cli): add custom keybinding documentation by @scidomino in
|
||||
[#21980](https://github.com/google-gemini/gemini-cli/pull/21980)
|
||||
- docs: fix misleading YOLO mode description in defaultApprovalMode by
|
||||
@Gyanranjan-Priyam in
|
||||
[#21665](https://github.com/google-gemini/gemini-cli/pull/21665)
|
||||
- fix(core): display actual graph output in tracker_visualize tool by @anj-s in
|
||||
[#21455](https://github.com/google-gemini/gemini-cli/pull/21455)
|
||||
- fix(core): sanitize SSE-corrupted JSON and domain strings in error
|
||||
classification by @gsquared94 in
|
||||
[#21702](https://github.com/google-gemini/gemini-cli/pull/21702)
|
||||
- Docs: Make documentation links relative by @diodesign in
|
||||
[#21490](https://github.com/google-gemini/gemini-cli/pull/21490)
|
||||
- feat(cli): expose /tools desc as explicit subcommand for discoverability by
|
||||
@aworki in [#21241](https://github.com/google-gemini/gemini-cli/pull/21241)
|
||||
- feat(cli): add /compact alias for /compress command by @jackwotherspoon in
|
||||
[#21711](https://github.com/google-gemini/gemini-cli/pull/21711)
|
||||
- feat(plan): enable Plan Mode by default by @jerop in
|
||||
[#21713](https://github.com/google-gemini/gemini-cli/pull/21713)
|
||||
- feat(core): Introduce `AgentLoopContext`. by @joshualitt in
|
||||
[#21198](https://github.com/google-gemini/gemini-cli/pull/21198)
|
||||
- fix(core): resolve symlinks for non-existent paths during validation by
|
||||
@Adib234 in [#21487](https://github.com/google-gemini/gemini-cli/pull/21487)
|
||||
- docs: document tool exclusion from memory via deny policy by @Abhijit-2592 in
|
||||
[#21428](https://github.com/google-gemini/gemini-cli/pull/21428)
|
||||
- perf(core): cache loadApiKey to reduce redundant keychain access by @sehoon38
|
||||
in [#21520](https://github.com/google-gemini/gemini-cli/pull/21520)
|
||||
- feat(cli): implement /upgrade command by @sehoon38 in
|
||||
[#21511](https://github.com/google-gemini/gemini-cli/pull/21511)
|
||||
- Feat/browser agent progress emission by @kunal-10-cloud in
|
||||
[#21218](https://github.com/google-gemini/gemini-cli/pull/21218)
|
||||
- fix(settings): display objects as JSON instead of [object Object] by
|
||||
@Zheyuan-Lin in
|
||||
[#21458](https://github.com/google-gemini/gemini-cli/pull/21458)
|
||||
- Unmarshall update by @DavidAPierce in
|
||||
[#21721](https://github.com/google-gemini/gemini-cli/pull/21721)
|
||||
- Update mcp's list function to check for disablement. by @DavidAPierce in
|
||||
[#21148](https://github.com/google-gemini/gemini-cli/pull/21148)
|
||||
- robustness(core): static checks to validate history is immutable by @jacob314
|
||||
in [#21228](https://github.com/google-gemini/gemini-cli/pull/21228)
|
||||
- refactor(cli): better react patterns for BaseSettingsDialog by @psinha40898 in
|
||||
[#21206](https://github.com/google-gemini/gemini-cli/pull/21206)
|
||||
- feat(security): implement robust IP validation and safeFetch foundation by
|
||||
@alisa-alisa in
|
||||
[#21401](https://github.com/google-gemini/gemini-cli/pull/21401)
|
||||
- feat(core): improve subagent result display by @joshualitt in
|
||||
[#20378](https://github.com/google-gemini/gemini-cli/pull/20378)
|
||||
- docs: fix broken markdown syntax and anchor links in /tools by @campox747 in
|
||||
[#20902](https://github.com/google-gemini/gemini-cli/pull/20902)
|
||||
- feat(policy): support subagent-specific policies in TOML by @akh64bit in
|
||||
[#21431](https://github.com/google-gemini/gemini-cli/pull/21431)
|
||||
- Add script to speed up reviewing PRs adding a worktree. by @jacob314 in
|
||||
[#21748](https://github.com/google-gemini/gemini-cli/pull/21748)
|
||||
- fix(core): prevent infinite recursion in symlink resolution by @Adib234 in
|
||||
[#21750](https://github.com/google-gemini/gemini-cli/pull/21750)
|
||||
- fix(docs): fix headless mode docs by @ame2en in
|
||||
[#21287](https://github.com/google-gemini/gemini-cli/pull/21287)
|
||||
- feat/redesign header compact by @jacob314 in
|
||||
[#20922](https://github.com/google-gemini/gemini-cli/pull/20922)
|
||||
- refactor: migrate to useKeyMatchers hook by @scidomino in
|
||||
[#21753](https://github.com/google-gemini/gemini-cli/pull/21753)
|
||||
- perf(cli): cache loadSettings to reduce redundant disk I/O at startup by
|
||||
@sehoon38 in [#21521](https://github.com/google-gemini/gemini-cli/pull/21521)
|
||||
- fix(core): resolve Windows line ending and path separation bugs across CLI by
|
||||
@muhammadusman586 in
|
||||
[#21068](https://github.com/google-gemini/gemini-cli/pull/21068)
|
||||
- docs: fix heading formatting in commands.md and phrasing in tools-api.md by
|
||||
@campox747 in [#20679](https://github.com/google-gemini/gemini-cli/pull/20679)
|
||||
- refactor(ui): unify keybinding infrastructure and support string
|
||||
initialization by @scidomino in
|
||||
[#21776](https://github.com/google-gemini/gemini-cli/pull/21776)
|
||||
- Add support for updating extension sources and names by @chrstnb in
|
||||
[#21715](https://github.com/google-gemini/gemini-cli/pull/21715)
|
||||
- fix(core): handle GUI editor non-zero exit codes gracefully by @reyyanxahmed
|
||||
in [#20376](https://github.com/google-gemini/gemini-cli/pull/20376)
|
||||
- fix(core): destroy PTY on kill() and exception to prevent fd leak by @nbardy
|
||||
in [#21693](https://github.com/google-gemini/gemini-cli/pull/21693)
|
||||
- fix(docs): update theme screenshots and add missing themes by @ashmod in
|
||||
[#20689](https://github.com/google-gemini/gemini-cli/pull/20689)
|
||||
- refactor(cli): rename 'return' key to 'enter' internally by @scidomino in
|
||||
[#21796](https://github.com/google-gemini/gemini-cli/pull/21796)
|
||||
- build(release): restrict npm bundling to non-stable tags by @sehoon38 in
|
||||
[#21821](https://github.com/google-gemini/gemini-cli/pull/21821)
|
||||
- fix(core): override toolRegistry property for sub-agent schedulers by
|
||||
@gsquared94 in
|
||||
[#21766](https://github.com/google-gemini/gemini-cli/pull/21766)
|
||||
- fix(cli): make footer items equally spaced by @jacob314 in
|
||||
[#21843](https://github.com/google-gemini/gemini-cli/pull/21843)
|
||||
- docs: clarify global policy rules application in plan mode by @jerop in
|
||||
[#21864](https://github.com/google-gemini/gemini-cli/pull/21864)
|
||||
- fix(core): ensure correct flash model steering in plan mode implementation
|
||||
phase by @jerop in
|
||||
[#21871](https://github.com/google-gemini/gemini-cli/pull/21871)
|
||||
- fix(core): update @a2a-js/sdk to 0.3.11 by @adamfweidman in
|
||||
[#21875](https://github.com/google-gemini/gemini-cli/pull/21875)
|
||||
- refactor(core): improve API response error logging when retry by @yunaseoul in
|
||||
[#21784](https://github.com/google-gemini/gemini-cli/pull/21784)
|
||||
- fix(ui): handle headless execution in credits and upgrade dialogs by
|
||||
@gsquared94 in
|
||||
[#21850](https://github.com/google-gemini/gemini-cli/pull/21850)
|
||||
- fix(core): treat retryable errors with >5 min delay as terminal quota errors
|
||||
by @gsquared94 in
|
||||
[#21881](https://github.com/google-gemini/gemini-cli/pull/21881)
|
||||
- feat(telemetry): add specific PR, issue, and custom tracking IDs for GitHub
|
||||
Actions by @cocosheng-g in
|
||||
[#21129](https://github.com/google-gemini/gemini-cli/pull/21129)
|
||||
- feat(core): add OAuth2 Authorization Code auth provider for A2A agents by
|
||||
@SandyTao520 in
|
||||
[#21496](https://github.com/google-gemini/gemini-cli/pull/21496)
|
||||
- feat(cli): give visibility to /tools list command in the TUI and follow the
|
||||
subcommand pattern of other commands by @JayadityaGit in
|
||||
[#21213](https://github.com/google-gemini/gemini-cli/pull/21213)
|
||||
- Handle dirty worktrees better and warn about running scripts/review.sh on
|
||||
untrusted code. by @jacob314 in
|
||||
[#21791](https://github.com/google-gemini/gemini-cli/pull/21791)
|
||||
- feat(policy): support auto-add to policy by default and scoped persistence by
|
||||
[#21878](https://github.com/google-gemini/gemini-cli/pull/21878)
|
||||
- fix: clean up /clear and /resume by @jackwotherspoon in
|
||||
[#22007](https://github.com/google-gemini/gemini-cli/pull/22007)
|
||||
- fix(core)#20941: reap orphaned descendant processes on PTY abort by @manavmax
|
||||
in [#21124](https://github.com/google-gemini/gemini-cli/pull/21124)
|
||||
- fix(core): update language detection to use LSP 3.18 identifiers by @yunaseoul
|
||||
in [#21931](https://github.com/google-gemini/gemini-cli/pull/21931)
|
||||
- feat(cli): support removing keybindings via '-' prefix by @scidomino in
|
||||
[#22042](https://github.com/google-gemini/gemini-cli/pull/22042)
|
||||
- feat(policy): add --admin-policy flag for supplemental admin policies by
|
||||
@galz10 in [#20360](https://github.com/google-gemini/gemini-cli/pull/20360)
|
||||
- merge duplicate imports packages/cli/src subtask1 by @Nixxx19 in
|
||||
[#22040](https://github.com/google-gemini/gemini-cli/pull/22040)
|
||||
- perf(core): parallelize user quota and experiments fetching in refreshAuth by
|
||||
@sehoon38 in [#21648](https://github.com/google-gemini/gemini-cli/pull/21648)
|
||||
- Changelog for v0.34.0-preview.0 by @gemini-cli-robot in
|
||||
[#21965](https://github.com/google-gemini/gemini-cli/pull/21965)
|
||||
- Changelog for v0.33.0 by @gemini-cli-robot in
|
||||
[#21967](https://github.com/google-gemini/gemini-cli/pull/21967)
|
||||
- fix(core): handle EISDIR in robustRealpath on Windows by @sehoon38 in
|
||||
[#21984](https://github.com/google-gemini/gemini-cli/pull/21984)
|
||||
- feat(core): include initiationMethod in conversation interaction telemetry by
|
||||
@yunaseoul in [#22054](https://github.com/google-gemini/gemini-cli/pull/22054)
|
||||
- feat(ui): add vim yank/paste (y/p/P) with unnamed register by @aanari in
|
||||
[#22026](https://github.com/google-gemini/gemini-cli/pull/22026)
|
||||
- fix(core): enable numerical routing for api key users by @sehoon38 in
|
||||
[#21977](https://github.com/google-gemini/gemini-cli/pull/21977)
|
||||
- feat(telemetry): implement retry attempt telemetry for network related retries
|
||||
by @aishaneeshah in
|
||||
[#22027](https://github.com/google-gemini/gemini-cli/pull/22027)
|
||||
- fix(policy): remove unnecessary escapeRegex from pattern builders by
|
||||
@spencer426 in
|
||||
[#20361](https://github.com/google-gemini/gemini-cli/pull/20361)
|
||||
- fix(core): handle AbortError when ESC cancels tool execution by @PrasannaPal21
|
||||
in [#20863](https://github.com/google-gemini/gemini-cli/pull/20863)
|
||||
- fix(release): Improve Patch Release Workflow Comments: Clearer Approval
|
||||
Guidance by @jerop in
|
||||
[#21894](https://github.com/google-gemini/gemini-cli/pull/21894)
|
||||
- docs: clarify telemetry setup and comprehensive data map by @jerop in
|
||||
[#21879](https://github.com/google-gemini/gemini-cli/pull/21879)
|
||||
- feat(core): add per-model token usage to stream-json output by @yongruilin in
|
||||
[#21839](https://github.com/google-gemini/gemini-cli/pull/21839)
|
||||
- docs: remove experimental badge from plan mode in sidebar by @jerop in
|
||||
[#21906](https://github.com/google-gemini/gemini-cli/pull/21906)
|
||||
- fix(cli): prevent race condition in loop detection retry by @skyvanguard in
|
||||
[#17916](https://github.com/google-gemini/gemini-cli/pull/17916)
|
||||
- Add behavioral evals for tracker by @anj-s in
|
||||
[#20069](https://github.com/google-gemini/gemini-cli/pull/20069)
|
||||
- fix(auth): update terminology to 'sign in' and 'sign out' by @clocky in
|
||||
[#20892](https://github.com/google-gemini/gemini-cli/pull/20892)
|
||||
- docs(mcp): standardize mcp tool fqn documentation by @abhipatel12 in
|
||||
[#21664](https://github.com/google-gemini/gemini-cli/pull/21664)
|
||||
- fix(ui): prevent empty tool-group border stubs after filtering by @Aaxhirrr in
|
||||
[#21852](https://github.com/google-gemini/gemini-cli/pull/21852)
|
||||
- make command names consistent by @scidomino in
|
||||
[#21907](https://github.com/google-gemini/gemini-cli/pull/21907)
|
||||
- refactor: remove agent_card_requires_auth config flag by @adamfweidman in
|
||||
[#21914](https://github.com/google-gemini/gemini-cli/pull/21914)
|
||||
- feat(a2a): implement standardized normalization and streaming reassembly by
|
||||
@alisa-alisa in
|
||||
[#21402](https://github.com/google-gemini/gemini-cli/pull/21402)
|
||||
- feat(cli): enable skill activation via slash commands by @NTaylorMullen in
|
||||
[#21758](https://github.com/google-gemini/gemini-cli/pull/21758)
|
||||
- docs(cli): mention per-model token usage in stream-json result event by
|
||||
@yongruilin in
|
||||
[#21908](https://github.com/google-gemini/gemini-cli/pull/21908)
|
||||
- fix(plan): prevent plan truncation in approval dialog by supporting
|
||||
unconstrained heights by @Adib234 in
|
||||
[#21037](https://github.com/google-gemini/gemini-cli/pull/21037)
|
||||
- feat(a2a): switch from callback-based to event-driven tool scheduler by
|
||||
@cocosheng-g in
|
||||
[#21467](https://github.com/google-gemini/gemini-cli/pull/21467)
|
||||
- feat(voice): implement speech-friendly response formatter by @Solventerritory
|
||||
in [#20989](https://github.com/google-gemini/gemini-cli/pull/20989)
|
||||
- feat: add pulsating blue border automation overlay to browser agent by
|
||||
@kunal-10-cloud in
|
||||
[#21173](https://github.com/google-gemini/gemini-cli/pull/21173)
|
||||
- Add extensionRegistryURI setting to change where the registry is read from by
|
||||
@kevinjwang1 in
|
||||
[#20463](https://github.com/google-gemini/gemini-cli/pull/20463)
|
||||
- fix: patch gaxios v7 Array.toString() stream corruption by @gsquared94 in
|
||||
[#21884](https://github.com/google-gemini/gemini-cli/pull/21884)
|
||||
- fix: prevent hangs in non-interactive mode and improve agent guidance by
|
||||
@cocosheng-g in
|
||||
[#20893](https://github.com/google-gemini/gemini-cli/pull/20893)
|
||||
- Add ExtensionDetails dialog and support install by @chrstnb in
|
||||
[#20845](https://github.com/google-gemini/gemini-cli/pull/20845)
|
||||
- chore/release: bump version to 0.34.0-nightly.20260310.4653b126f by
|
||||
@gemini-cli-robot in
|
||||
[#21816](https://github.com/google-gemini/gemini-cli/pull/21816)
|
||||
- Changelog for v0.33.0-preview.13 by @gemini-cli-robot in
|
||||
[#21927](https://github.com/google-gemini/gemini-cli/pull/21927)
|
||||
- fix(cli): stabilize prompt layout to prevent jumping when typing by
|
||||
[#21921](https://github.com/google-gemini/gemini-cli/pull/21921)
|
||||
- fix(core): preserve dynamic tool descriptions on session resume by @sehoon38
|
||||
in [#18835](https://github.com/google-gemini/gemini-cli/pull/18835)
|
||||
- chore: allow 'gemini-3.1' in sensitive keyword linter by @scidomino in
|
||||
[#22065](https://github.com/google-gemini/gemini-cli/pull/22065)
|
||||
- feat(core): support custom base URL via env vars by @junaiddshaukat in
|
||||
[#21561](https://github.com/google-gemini/gemini-cli/pull/21561)
|
||||
- merge duplicate imports packages/cli/src subtask2 by @Nixxx19 in
|
||||
[#22051](https://github.com/google-gemini/gemini-cli/pull/22051)
|
||||
- fix(core): silently retry API errors up to 3 times before halting session by
|
||||
@spencer426 in
|
||||
[#21989](https://github.com/google-gemini/gemini-cli/pull/21989)
|
||||
- feat(core): simplify subagent success UI and improve early termination display
|
||||
by @abhipatel12 in
|
||||
[#21917](https://github.com/google-gemini/gemini-cli/pull/21917)
|
||||
- merge duplicate imports packages/cli/src subtask3 by @Nixxx19 in
|
||||
[#22056](https://github.com/google-gemini/gemini-cli/pull/22056)
|
||||
- fix(hooks): fix BeforeAgent/AfterAgent inconsistencies (#18514) by @krishdef7
|
||||
in [#21383](https://github.com/google-gemini/gemini-cli/pull/21383)
|
||||
- feat(core): implement SandboxManager interface and config schema by @galz10 in
|
||||
[#21774](https://github.com/google-gemini/gemini-cli/pull/21774)
|
||||
- docs: document npm deprecation warnings as safe to ignore by @h30s in
|
||||
[#20692](https://github.com/google-gemini/gemini-cli/pull/20692)
|
||||
- fix: remove status/need-triage from maintainer-only issues by @SandyTao520 in
|
||||
[#22044](https://github.com/google-gemini/gemini-cli/pull/22044)
|
||||
- fix(core): propagate subagent context to policy engine by @NTaylorMullen in
|
||||
[#22086](https://github.com/google-gemini/gemini-cli/pull/22086)
|
||||
- fix(cli): resolve skill uninstall failure when skill name is updated by
|
||||
@NTaylorMullen in
|
||||
[#21081](https://github.com/google-gemini/gemini-cli/pull/21081)
|
||||
- fix: preserve prompt text when cancelling streaming by @Nixxx19 in
|
||||
[#21103](https://github.com/google-gemini/gemini-cli/pull/21103)
|
||||
- fix: robust UX for remote agent errors by @Shyam-Raghuwanshi in
|
||||
[#20307](https://github.com/google-gemini/gemini-cli/pull/20307)
|
||||
- feat: implement background process logging and cleanup by @galz10 in
|
||||
[#21189](https://github.com/google-gemini/gemini-cli/pull/21189)
|
||||
- Changelog for v0.33.0-preview.14 by @gemini-cli-robot in
|
||||
[#21938](https://github.com/google-gemini/gemini-cli/pull/21938)
|
||||
[#22085](https://github.com/google-gemini/gemini-cli/pull/22085)
|
||||
- docs(plan): clarify interactive plan editing with Ctrl+X by @Adib234 in
|
||||
[#22076](https://github.com/google-gemini/gemini-cli/pull/22076)
|
||||
- fix(policy): ensure user policies are loaded when policyPaths is empty by
|
||||
@NTaylorMullen in
|
||||
[#22090](https://github.com/google-gemini/gemini-cli/pull/22090)
|
||||
- Docs: Add documentation for model steering (experimental). by @jkcinouye in
|
||||
[#21154](https://github.com/google-gemini/gemini-cli/pull/21154)
|
||||
- Add issue for automated changelogs by @g-samroberts in
|
||||
[#21912](https://github.com/google-gemini/gemini-cli/pull/21912)
|
||||
- fix(core): secure argsPattern and revert WEB_FETCH_TOOL_NAME escalation by
|
||||
@spencer426 in
|
||||
[#22104](https://github.com/google-gemini/gemini-cli/pull/22104)
|
||||
- feat(core): differentiate User-Agent for a2a-server and ACP clients by
|
||||
@bdmorgan in [#22059](https://github.com/google-gemini/gemini-cli/pull/22059)
|
||||
- refactor(core): extract ExecutionLifecycleService for tool backgrounding by
|
||||
@adamfweidman in
|
||||
[#21717](https://github.com/google-gemini/gemini-cli/pull/21717)
|
||||
- feat: Display pending and confirming tool calls by @sripasg in
|
||||
[#22106](https://github.com/google-gemini/gemini-cli/pull/22106)
|
||||
- feat(browser): implement input blocker overlay during automation by
|
||||
@kunal-10-cloud in
|
||||
[#21132](https://github.com/google-gemini/gemini-cli/pull/21132)
|
||||
- fix: register themes on extension load not start by @jackwotherspoon in
|
||||
[#22148](https://github.com/google-gemini/gemini-cli/pull/22148)
|
||||
- feat(ui): Do not show Ultra users /upgrade hint (#22154) by @sehoon38 in
|
||||
[#22156](https://github.com/google-gemini/gemini-cli/pull/22156)
|
||||
- chore: remove unnecessary log for themes by @jackwotherspoon in
|
||||
[#22165](https://github.com/google-gemini/gemini-cli/pull/22165)
|
||||
- fix(core): resolve MCP tool FQN validation, schema export, and wildcards in
|
||||
subagents by @abhipatel12 in
|
||||
[#22069](https://github.com/google-gemini/gemini-cli/pull/22069)
|
||||
- fix(cli): validate --model argument at startup by @JaisalJain in
|
||||
[#21393](https://github.com/google-gemini/gemini-cli/pull/21393)
|
||||
- fix(core): handle policy ALLOW for exit_plan_mode by @backnotprop in
|
||||
[#21802](https://github.com/google-gemini/gemini-cli/pull/21802)
|
||||
- feat(telemetry): add Clearcut instrumentation for AI credits billing events by
|
||||
@gsquared94 in
|
||||
[#22153](https://github.com/google-gemini/gemini-cli/pull/22153)
|
||||
- feat(core): add google credentials provider for remote agents by @adamfweidman
|
||||
in [#21024](https://github.com/google-gemini/gemini-cli/pull/21024)
|
||||
- test(cli): add integration test for node deprecation warnings by @Nixxx19 in
|
||||
[#20215](https://github.com/google-gemini/gemini-cli/pull/20215)
|
||||
- feat(cli): allow safe tools to execute concurrently while agent is busy by
|
||||
@spencer426 in
|
||||
[#21988](https://github.com/google-gemini/gemini-cli/pull/21988)
|
||||
- feat(core): implement model-driven parallel tool scheduler by @abhipatel12 in
|
||||
[#21933](https://github.com/google-gemini/gemini-cli/pull/21933)
|
||||
- update vulnerable deps by @scidomino in
|
||||
[#22180](https://github.com/google-gemini/gemini-cli/pull/22180)
|
||||
- fix(core): fix startup stats to use int values for timestamps and durations by
|
||||
@yunaseoul in [#22201](https://github.com/google-gemini/gemini-cli/pull/22201)
|
||||
- fix(core): prevent duplicate tool schemas for instantiated tools by
|
||||
@abhipatel12 in
|
||||
[#22204](https://github.com/google-gemini/gemini-cli/pull/22204)
|
||||
- fix(core): add proxy routing support for remote A2A subagents by @adamfweidman
|
||||
in [#22199](https://github.com/google-gemini/gemini-cli/pull/22199)
|
||||
- fix(core/ide): add Antigravity CLI fallbacks by @apfine in
|
||||
[#22030](https://github.com/google-gemini/gemini-cli/pull/22030)
|
||||
- fix(browser): fix duplicate function declaration error in browser agent by
|
||||
@gsquared94 in
|
||||
[#22207](https://github.com/google-gemini/gemini-cli/pull/22207)
|
||||
- feat(core): implement Stage 1 improvements for webfetch tool by @aishaneeshah
|
||||
in [#21313](https://github.com/google-gemini/gemini-cli/pull/21313)
|
||||
- Changelog for v0.34.0-preview.1 by @gemini-cli-robot in
|
||||
[#22194](https://github.com/google-gemini/gemini-cli/pull/22194)
|
||||
- perf(cli): enable code splitting and deferred UI loading by @sehoon38 in
|
||||
[#22117](https://github.com/google-gemini/gemini-cli/pull/22117)
|
||||
- fix: remove unused img.png from project root by @SandyTao520 in
|
||||
[#22222](https://github.com/google-gemini/gemini-cli/pull/22222)
|
||||
- docs(local model routing): add docs on how to use Gemma for local model
|
||||
routing by @douglas-reid in
|
||||
[#21365](https://github.com/google-gemini/gemini-cli/pull/21365)
|
||||
- feat(a2a): enable native gRPC support and protocol routing by @alisa-alisa in
|
||||
[#21403](https://github.com/google-gemini/gemini-cli/pull/21403)
|
||||
- fix(cli): escape @ symbols on paste to prevent unintended file expansion by
|
||||
@krishdef7 in [#21239](https://github.com/google-gemini/gemini-cli/pull/21239)
|
||||
- feat(core): add trajectoryId to ConversationOffered telemetry by @yunaseoul in
|
||||
[#22214](https://github.com/google-gemini/gemini-cli/pull/22214)
|
||||
- docs: clarify that tools.core is an allowlist for ALL built-in tools by
|
||||
@hobostay in [#18813](https://github.com/google-gemini/gemini-cli/pull/18813)
|
||||
- docs(plan): document hooks with plan mode by @ruomengz in
|
||||
[#22197](https://github.com/google-gemini/gemini-cli/pull/22197)
|
||||
- Changelog for v0.33.1 by @gemini-cli-robot in
|
||||
[#22235](https://github.com/google-gemini/gemini-cli/pull/22235)
|
||||
- build(ci): fix false positive evals trigger on merge commits by @gundermanc in
|
||||
[#22237](https://github.com/google-gemini/gemini-cli/pull/22237)
|
||||
- fix(core): explicitly pass messageBus to policy engine for MCP tool saves by
|
||||
@abhipatel12 in
|
||||
[#22255](https://github.com/google-gemini/gemini-cli/pull/22255)
|
||||
- feat(core): Fully migrate packages/core to AgentLoopContext. by @joshualitt in
|
||||
[#22115](https://github.com/google-gemini/gemini-cli/pull/22115)
|
||||
- feat(core): increase sub-agent turn and time limits by @bdmorgan in
|
||||
[#22196](https://github.com/google-gemini/gemini-cli/pull/22196)
|
||||
- feat(core): instrument file system tools for JIT context discovery by
|
||||
@SandyTao520 in
|
||||
[#22082](https://github.com/google-gemini/gemini-cli/pull/22082)
|
||||
- refactor(ui): extract pure session browser utilities by @abhipatel12 in
|
||||
[#22256](https://github.com/google-gemini/gemini-cli/pull/22256)
|
||||
- fix(plan): Fix AskUser evals by @Adib234 in
|
||||
[#22074](https://github.com/google-gemini/gemini-cli/pull/22074)
|
||||
- fix(settings): prevent j/k navigation keys from intercepting edit buffer input
|
||||
by @student-ankitpandit in
|
||||
[#21865](https://github.com/google-gemini/gemini-cli/pull/21865)
|
||||
- feat(skills): improve async-pr-review workflow and logging by @mattKorwel in
|
||||
[#21790](https://github.com/google-gemini/gemini-cli/pull/21790)
|
||||
- refactor(cli): consolidate getErrorMessage utility to core by @scidomino in
|
||||
[#22190](https://github.com/google-gemini/gemini-cli/pull/22190)
|
||||
- fix(core): show descriptive error messages when saving settings fails by
|
||||
@afarber in [#18095](https://github.com/google-gemini/gemini-cli/pull/18095)
|
||||
- docs(core): add authentication guide for remote subagents by @adamfweidman in
|
||||
[#22178](https://github.com/google-gemini/gemini-cli/pull/22178)
|
||||
- docs: overhaul subagents documentation and add /agents command by @abhipatel12
|
||||
in [#22345](https://github.com/google-gemini/gemini-cli/pull/22345)
|
||||
- refactor(ui): extract SessionBrowser static ui components by @abhipatel12 in
|
||||
[#22348](https://github.com/google-gemini/gemini-cli/pull/22348)
|
||||
- test: add Object.create context regression test and tool confirmation
|
||||
integration test by @gsquared94 in
|
||||
[#22356](https://github.com/google-gemini/gemini-cli/pull/22356)
|
||||
- feat(tracker): return TodoList display for tracker tools by @anj-s in
|
||||
[#22060](https://github.com/google-gemini/gemini-cli/pull/22060)
|
||||
- feat(agent): add allowed domain restrictions for browser agent by
|
||||
@cynthialong0-0 in
|
||||
[#21775](https://github.com/google-gemini/gemini-cli/pull/21775)
|
||||
- chore/release: bump version to 0.35.0-nightly.20260313.bb060d7a9 by
|
||||
@gemini-cli-robot in
|
||||
[#22251](https://github.com/google-gemini/gemini-cli/pull/22251)
|
||||
- Move keychain fallback to keychain service by @chrstnb in
|
||||
[#22332](https://github.com/google-gemini/gemini-cli/pull/22332)
|
||||
- feat(core): integrate SandboxManager to sandbox all process-spawning tools by
|
||||
@galz10 in [#22231](https://github.com/google-gemini/gemini-cli/pull/22231)
|
||||
- fix(cli): support CJK input and full Unicode scalar values in terminal
|
||||
protocols by @scidomino in
|
||||
[#22353](https://github.com/google-gemini/gemini-cli/pull/22353)
|
||||
- Promote stable tests. by @gundermanc in
|
||||
[#22253](https://github.com/google-gemini/gemini-cli/pull/22253)
|
||||
- feat(tracker): add tracker policy by @anj-s in
|
||||
[#22379](https://github.com/google-gemini/gemini-cli/pull/22379)
|
||||
- feat(security): add disableAlwaysAllow setting to disable auto-approvals by
|
||||
@galz10 in [#21941](https://github.com/google-gemini/gemini-cli/pull/21941)
|
||||
- Revert "fix(cli): validate --model argument at startup" by @sehoon38 in
|
||||
[#22378](https://github.com/google-gemini/gemini-cli/pull/22378)
|
||||
- fix(mcp): handle equivalent root resource URLs in OAuth validation by @galz10
|
||||
in [#20231](https://github.com/google-gemini/gemini-cli/pull/20231)
|
||||
- fix(core): use session-specific temp directory for task tracker by @anj-s in
|
||||
[#22382](https://github.com/google-gemini/gemini-cli/pull/22382)
|
||||
- Fix issue where config was undefined. by @gundermanc in
|
||||
[#22397](https://github.com/google-gemini/gemini-cli/pull/22397)
|
||||
- fix(core): deduplicate project memory when JIT context is enabled by
|
||||
@SandyTao520 in
|
||||
[#22234](https://github.com/google-gemini/gemini-cli/pull/22234)
|
||||
- feat(prompts): implement Topic-Action-Summary model for verbosity reduction by
|
||||
@Abhijit-2592 in
|
||||
[#21503](https://github.com/google-gemini/gemini-cli/pull/21503)
|
||||
- fix(core): fix manual deletion of subagent histories by @abhipatel12 in
|
||||
[#22407](https://github.com/google-gemini/gemini-cli/pull/22407)
|
||||
- Add registry var by @kevinjwang1 in
|
||||
[#22224](https://github.com/google-gemini/gemini-cli/pull/22224)
|
||||
- Add ModelDefinitions to ModelConfigService by @kevinjwang1 in
|
||||
[#22302](https://github.com/google-gemini/gemini-cli/pull/22302)
|
||||
- fix(cli): improve command conflict handling for skills by @NTaylorMullen in
|
||||
[#21942](https://github.com/google-gemini/gemini-cli/pull/21942)
|
||||
- fix(core): merge user settings with extension-provided MCP servers by
|
||||
@abhipatel12 in
|
||||
[#22484](https://github.com/google-gemini/gemini-cli/pull/22484)
|
||||
- fix(core): skip discovery for incomplete MCP configs and resolve merge race
|
||||
condition by @abhipatel12 in
|
||||
[#22494](https://github.com/google-gemini/gemini-cli/pull/22494)
|
||||
- fix(automation): harden stale PR closer permissions and maintainer detection
|
||||
by @bdmorgan in
|
||||
[#22558](https://github.com/google-gemini/gemini-cli/pull/22558)
|
||||
- fix(automation): evaluate staleness before checking protected labels by
|
||||
@bdmorgan in [#22561](https://github.com/google-gemini/gemini-cli/pull/22561)
|
||||
- feat(agent): replace the runtime npx for browser agent chrome devtool mcp with
|
||||
pre-built bundle by @cynthialong0-0 in
|
||||
[#22213](https://github.com/google-gemini/gemini-cli/pull/22213)
|
||||
- perf: optimize TrackerService dependency checks by @anj-s in
|
||||
[#22384](https://github.com/google-gemini/gemini-cli/pull/22384)
|
||||
- docs(policy): remove trailing space from commandPrefix examples by @kawasin73
|
||||
in [#22264](https://github.com/google-gemini/gemini-cli/pull/22264)
|
||||
- fix(a2a-server): resolve unsafe assignment lint errors by @ehedlund in
|
||||
[#22661](https://github.com/google-gemini/gemini-cli/pull/22661)
|
||||
- fix: Adjust ToolGroupMessage filtering to hide Confirming and show Canceled
|
||||
tool calls. by @sripasg in
|
||||
[#22230](https://github.com/google-gemini/gemini-cli/pull/22230)
|
||||
- Disallow Object.create() and reflect. by @gundermanc in
|
||||
[#22408](https://github.com/google-gemini/gemini-cli/pull/22408)
|
||||
- Guard pro model usage by @sehoon38 in
|
||||
[#22665](https://github.com/google-gemini/gemini-cli/pull/22665)
|
||||
- refactor(core): Creates AgentSession abstraction for consolidated agent
|
||||
interface. by @mbleigh in
|
||||
[#22270](https://github.com/google-gemini/gemini-cli/pull/22270)
|
||||
- docs(changelog): remove internal commands from release notes by
|
||||
@jackwotherspoon in
|
||||
[#22529](https://github.com/google-gemini/gemini-cli/pull/22529)
|
||||
- feat: enable subagents by @abhipatel12 in
|
||||
[#22386](https://github.com/google-gemini/gemini-cli/pull/22386)
|
||||
- feat(extensions): implement cryptographic integrity verification for extension
|
||||
updates by @ehedlund in
|
||||
[#21772](https://github.com/google-gemini/gemini-cli/pull/21772)
|
||||
- feat(tracker): polish UI sorting and formatting by @anj-s in
|
||||
[#22437](https://github.com/google-gemini/gemini-cli/pull/22437)
|
||||
- Changelog for v0.34.0-preview.2 by @gemini-cli-robot in
|
||||
[#22220](https://github.com/google-gemini/gemini-cli/pull/22220)
|
||||
- fix(core): fix three JIT context bugs in read_file, read_many_files, and
|
||||
memoryDiscovery by @SandyTao520 in
|
||||
[#22679](https://github.com/google-gemini/gemini-cli/pull/22679)
|
||||
- refactor(core): introduce InjectionService with source-aware injection and
|
||||
backend-native background completions by @adamfweidman in
|
||||
[#22544](https://github.com/google-gemini/gemini-cli/pull/22544)
|
||||
- Linux sandbox bubblewrap by @DavidAPierce in
|
||||
[#22680](https://github.com/google-gemini/gemini-cli/pull/22680)
|
||||
- feat(core): increase thought signature retry resilience by @bdmorgan in
|
||||
[#22202](https://github.com/google-gemini/gemini-cli/pull/22202)
|
||||
- feat(core): implement Stage 2 security and consistency improvements for
|
||||
web_fetch by @aishaneeshah in
|
||||
[#22217](https://github.com/google-gemini/gemini-cli/pull/22217)
|
||||
- refactor(core): replace positional execute params with ExecuteOptions bag by
|
||||
@adamfweidman in
|
||||
[#22674](https://github.com/google-gemini/gemini-cli/pull/22674)
|
||||
- feat(config): enable JIT context loading by default by @SandyTao520 in
|
||||
[#22736](https://github.com/google-gemini/gemini-cli/pull/22736)
|
||||
- fix(config): ensure discoveryMaxDirs is passed to global config during
|
||||
initialization by @kevin-ramdass in
|
||||
[#22744](https://github.com/google-gemini/gemini-cli/pull/22744)
|
||||
- fix(plan): allowlist get_internal_docs in Plan Mode by @Adib234 in
|
||||
[#22668](https://github.com/google-gemini/gemini-cli/pull/22668)
|
||||
- Changelog for v0.34.0-preview.3 by @gemini-cli-robot in
|
||||
[#22393](https://github.com/google-gemini/gemini-cli/pull/22393)
|
||||
- feat(core): add foundation for subagent tool isolation by @akh64bit in
|
||||
[#22708](https://github.com/google-gemini/gemini-cli/pull/22708)
|
||||
- fix(core): handle surrogate pairs in truncateString by @sehoon38 in
|
||||
[#22754](https://github.com/google-gemini/gemini-cli/pull/22754)
|
||||
- fix(cli): override j/k navigation in settings dialog to fix search input
|
||||
conflict by @sehoon38 in
|
||||
[#22800](https://github.com/google-gemini/gemini-cli/pull/22800)
|
||||
- feat(plan): add 'All the above' option to multi-select AskUser questions by
|
||||
@Adib234 in [#22365](https://github.com/google-gemini/gemini-cli/pull/22365)
|
||||
- docs: distribute package-specific GEMINI.md context to each package by
|
||||
@SandyTao520 in
|
||||
[#22734](https://github.com/google-gemini/gemini-cli/pull/22734)
|
||||
- fix(cli): clean up stale pasted placeholder metadata after word/line deletions
|
||||
by @Jomak-x in
|
||||
[#20375](https://github.com/google-gemini/gemini-cli/pull/20375)
|
||||
- refactor(core): align JIT memory placement with tiered context model by
|
||||
@SandyTao520 in
|
||||
[#22766](https://github.com/google-gemini/gemini-cli/pull/22766)
|
||||
- Linux sandbox seccomp by @DavidAPierce in
|
||||
[#22815](https://github.com/google-gemini/gemini-cli/pull/22815)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.33.0-preview.15...v0.34.0-preview.4
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.34.0-preview.4...v0.35.0-preview.1
|
||||
|
||||
@@ -460,6 +460,26 @@ Manual deletion also removes all associated artifacts:
|
||||
If you use a [custom plans directory](#custom-plan-directory-and-policies),
|
||||
those files are not automatically deleted and must be managed manually.
|
||||
|
||||
## Non-interactive execution
|
||||
|
||||
When running Gemini CLI in non-interactive environments (such as headless
|
||||
scripts or CI/CD pipelines), Plan Mode optimizes for automated workflows:
|
||||
|
||||
- **Automatic transitions:** The policy engine automatically approves the
|
||||
`enter_plan_mode` and `exit_plan_mode` tools without prompting for user
|
||||
confirmation.
|
||||
- **Automated implementation:** When exiting Plan Mode to execute the plan,
|
||||
Gemini CLI automatically switches to
|
||||
[YOLO mode](../reference/policy-engine.md#approval-modes) instead of the
|
||||
standard Default mode. This allows the CLI to execute the implementation steps
|
||||
automatically without hanging on interactive tool approvals.
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
gemini --approval-mode plan -p "Analyze telemetry and suggest improvements"
|
||||
```
|
||||
|
||||
[`plan.toml`]:
|
||||
https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml
|
||||
[Conductor]: https://github.com/gemini-cli-extensions/conductor
|
||||
|
||||
@@ -90,6 +90,17 @@ If `argsPattern` is specified, the tool's arguments are converted to a stable
|
||||
JSON string, which is then tested against the provided regular expression. If
|
||||
the arguments don't match the pattern, the rule does not apply.
|
||||
|
||||
#### Execution environment
|
||||
|
||||
If `interactive` is specified, the rule will only apply if the CLI's execution
|
||||
environment matches the specified boolean value:
|
||||
|
||||
- `true`: The rule applies only in interactive mode.
|
||||
- `false`: The rule applies only in non-interactive (headless) mode.
|
||||
|
||||
If omitted, the rule applies to both interactive and non-interactive
|
||||
environments.
|
||||
|
||||
### Decisions
|
||||
|
||||
There are three possible decisions a rule can enforce:
|
||||
@@ -286,6 +297,10 @@ deny_message = "Deletion is permanent"
|
||||
|
||||
# (Optional) An array of approval modes where this rule is active.
|
||||
modes = ["autoEdit"]
|
||||
|
||||
# (Optional) A boolean to restrict the rule to interactive (true) or non-interactive (false) environments.
|
||||
# If omitted, the rule applies to both.
|
||||
interactive = true
|
||||
```
|
||||
|
||||
### Using arrays (lists)
|
||||
@@ -360,6 +375,8 @@ priority = 200
|
||||
Specify only the `mcpName` to apply a rule to every tool provided by that
|
||||
server.
|
||||
|
||||
**Note:** This applies to all decision types (`allow`, `deny`, `ask_user`).
|
||||
|
||||
```toml
|
||||
# Denies all tools from the `untrusted-server` MCP
|
||||
[[rule]]
|
||||
|
||||
@@ -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
|
||||
|
||||
+91
-26
@@ -18,6 +18,18 @@ describe('plan_mode', () => {
|
||||
experimental: { plan: true },
|
||||
};
|
||||
|
||||
const getWriteTargets = (logs: any[]) =>
|
||||
logs
|
||||
.filter((log) => ['write_file', 'replace'].includes(log.toolRequest.name))
|
||||
.map((log) => {
|
||||
try {
|
||||
return JSON.parse(log.toolRequest.args).file_path as string;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: 'should refuse file modification when in plan mode',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
@@ -32,27 +44,23 @@ describe('plan_mode', () => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const writeTargets = toolLogs
|
||||
.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
)
|
||||
.map((log) => {
|
||||
try {
|
||||
return JSON.parse(log.toolRequest.args).file_path;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const exitPlanIndex = toolLogs.findIndex(
|
||||
(log) => log.toolRequest.name === 'exit_plan_mode',
|
||||
);
|
||||
|
||||
const writeTargetsBeforeExitPlan = getWriteTargets(
|
||||
toolLogs.slice(0, exitPlanIndex !== -1 ? exitPlanIndex : undefined),
|
||||
);
|
||||
|
||||
expect(
|
||||
writeTargets,
|
||||
writeTargetsBeforeExitPlan,
|
||||
'Should not attempt to modify README.md in plan mode',
|
||||
).not.toContain('README.md');
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/plan mode|read-only|cannot modify|refuse|exiting/i],
|
||||
testName: `${TEST_PREFIX}should refuse file modification`,
|
||||
testName: `${TEST_PREFIX}should refuse file modification in plan mode`,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -69,24 +77,20 @@ describe('plan_mode', () => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const writeTargets = toolLogs
|
||||
.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
)
|
||||
.map((log) => {
|
||||
try {
|
||||
return JSON.parse(log.toolRequest.args).file_path;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const exitPlanIndex = toolLogs.findIndex(
|
||||
(log) => log.toolRequest.name === 'exit_plan_mode',
|
||||
);
|
||||
|
||||
const writeTargetsBeforeExit = getWriteTargets(
|
||||
toolLogs.slice(0, exitPlanIndex !== -1 ? exitPlanIndex : undefined),
|
||||
);
|
||||
|
||||
// It should NOT write to the docs folder or any other repo path
|
||||
const hasRepoWrite = writeTargets.some(
|
||||
const hasRepoWriteBeforeExit = writeTargetsBeforeExit.some(
|
||||
(path) => path && !path.includes('/plans/'),
|
||||
);
|
||||
expect(
|
||||
hasRepoWrite,
|
||||
hasRepoWriteBeforeExit,
|
||||
'Should not attempt to create files in the repository while in plan mode',
|
||||
).toBe(false);
|
||||
|
||||
@@ -166,4 +170,65 @@ describe('plan_mode', () => {
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should create a plan in plan mode and implement it for a refactoring task',
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
files: {
|
||||
'src/mathUtils.ts':
|
||||
'export const sum = (a: number, b: number) => a + b;\nexport const multiply = (a: number, b: number) => a * b;',
|
||||
'src/main.ts':
|
||||
'import { sum } from "./mathUtils";\nconsole.log(sum(1, 2));',
|
||||
},
|
||||
prompt:
|
||||
'I want to refactor our math utilities. Move the `sum` function from `src/mathUtils.ts` to a new file `src/basicMath.ts` and update `src/main.ts` to use the new file. Please create a detailed implementation plan first, then execute it.',
|
||||
assert: async (rig, result) => {
|
||||
const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode');
|
||||
expect(
|
||||
enterPlanCalled,
|
||||
'Expected enter_plan_mode tool to be called',
|
||||
).toBe(true);
|
||||
|
||||
const exitPlanCalled = await rig.waitForToolCall('exit_plan_mode');
|
||||
expect(exitPlanCalled, 'Expected exit_plan_mode tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
// Check if plan was written
|
||||
const planWrite = toolLogs.find(
|
||||
(log) =>
|
||||
log.toolRequest.name === 'write_file' &&
|
||||
log.toolRequest.args.includes('/plans/'),
|
||||
);
|
||||
expect(
|
||||
planWrite,
|
||||
'Expected a plan file to be written in the plans directory',
|
||||
).toBeDefined();
|
||||
|
||||
// Check for implementation files
|
||||
const newFileWrite = toolLogs.find(
|
||||
(log) =>
|
||||
log.toolRequest.name === 'write_file' &&
|
||||
log.toolRequest.args.includes('src/basicMath.ts'),
|
||||
);
|
||||
expect(
|
||||
newFileWrite,
|
||||
'Expected src/basicMath.ts to be created',
|
||||
).toBeDefined();
|
||||
|
||||
const mainUpdate = toolLogs.find(
|
||||
(log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name) &&
|
||||
log.toolRequest.args.includes('src/main.ts'),
|
||||
);
|
||||
expect(mainUpdate, 'Expected src/main.ts to be updated').toBeDefined();
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -35,7 +35,11 @@ export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
|
||||
? Math.min(availableHeightLimit, maxLines)
|
||||
: (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT);
|
||||
|
||||
const lastLines = disableTruncation ? data : data.slice(-numLinesRetained);
|
||||
const lastLines = disableTruncation
|
||||
? data
|
||||
: numLinesRetained === 0
|
||||
? []
|
||||
: data.slice(-numLinesRetained);
|
||||
return (
|
||||
<Box flexDirection="column" width={width} flexShrink={0} overflow="hidden">
|
||||
{lastLines.map((line: AnsiLine, lineIndex: number) => (
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { Banner } from './Banner.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
@@ -12,22 +12,23 @@ describe('Banner', () => {
|
||||
it.each([
|
||||
['warning mode', true, 'Warning Message'],
|
||||
['info mode', false, 'Info Message'],
|
||||
['multi-line warning', true, 'Title Line\\nBody Line 1\\nBody Line 2'],
|
||||
])('renders in %s', async (_, isWarning, text) => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
const renderResult = renderWithProviders(
|
||||
<Banner bannerText={text} isWarning={isWarning} width={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
await renderResult.waitUntilReady();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
renderResult.unmount();
|
||||
});
|
||||
|
||||
it('handles newlines in text', async () => {
|
||||
const text = 'Line 1\\nLine 2';
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
const renderResult = renderWithProviders(
|
||||
<Banner bannerText={text} isWarning={false} width={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
await renderResult.waitUntilReady();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
renderResult.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,20 +14,21 @@ export function getFormattedBannerContent(
|
||||
isWarning: boolean,
|
||||
subsequentLineColor: string,
|
||||
): ReactNode {
|
||||
if (isWarning) {
|
||||
return (
|
||||
<Text color={theme.status.warning}>{rawText.replace(/\\n/g, '\n')}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const text = rawText.replace(/\\n/g, '\n');
|
||||
const lines = text.split('\n');
|
||||
|
||||
return lines.map((line, index) => {
|
||||
if (index === 0) {
|
||||
if (isWarning) {
|
||||
return (
|
||||
<Text key={index} bold color={theme.status.warning}>
|
||||
{line}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ThemedGradient key={index}>
|
||||
<Text>{line}</Text>
|
||||
<Text bold>{line}</Text>
|
||||
</ThemedGradient>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export const MainContent = () => {
|
||||
pendingHistoryItems,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
availableTerminalHeight,
|
||||
cleanUiDetailsVisible,
|
||||
} = uiState;
|
||||
const showHeaderDetails = cleanUiDetailsVisible;
|
||||
@@ -141,7 +142,7 @@ export const MainContent = () => {
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight ? staticAreaMaxItemHeight : undefined
|
||||
uiState.constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
@@ -160,7 +161,7 @@ export const MainContent = () => {
|
||||
[
|
||||
pendingHistoryItems,
|
||||
uiState.constrainHeight,
|
||||
staticAreaMaxItemHeight,
|
||||
availableTerminalHeight,
|
||||
mainAreaWidth,
|
||||
showConfirmationQueue,
|
||||
confirmingTool,
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="88" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#333333" textLength="720" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">L</text>
|
||||
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">i</text>
|
||||
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">n</text>
|
||||
<text x="45" y="19" fill="#9974b4" textLength="9" lengthAdjust="spacingAndGlyphs">e</text>
|
||||
<text x="63" y="19" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">1</text>
|
||||
<text x="711" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="36" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">Line 2</text>
|
||||
<text x="711" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="53" fill="#333333" textLength="720" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
+23
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#333333" textLength="720" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">I</text>
|
||||
<text x="27" y="19" fill="#5390e0" textLength="9" lengthAdjust="spacingAndGlyphs">n</text>
|
||||
<text x="36" y="19" fill="#5f8bdb" textLength="9" lengthAdjust="spacingAndGlyphs">f</text>
|
||||
<text x="45" y="19" fill="#6c85d7" textLength="9" lengthAdjust="spacingAndGlyphs">o</text>
|
||||
<text x="63" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">M</text>
|
||||
<text x="72" y="19" fill="#8f77c1" textLength="9" lengthAdjust="spacingAndGlyphs">e</text>
|
||||
<text x="81" y="19" fill="#9974b4" textLength="9" lengthAdjust="spacingAndGlyphs">s</text>
|
||||
<text x="90" y="19" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">s</text>
|
||||
<text x="99" y="19" fill="#ae6d99" textLength="9" lengthAdjust="spacingAndGlyphs">a</text>
|
||||
<text x="108" y="19" fill="#b96a8c" textLength="9" lengthAdjust="spacingAndGlyphs">g</text>
|
||||
<text x="117" y="19" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">e</text>
|
||||
<text x="711" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#333333" textLength="720" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
+19
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="105" viewBox="0 0 920 105">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="105" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#ffffaf" textLength="720" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="19" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#ffffaf" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Title Line</text>
|
||||
<text x="711" y="19" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="36" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs">Body Line 1</text>
|
||||
<text x="711" y="36" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="53" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="53" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs">Body Line 2</text>
|
||||
<text x="711" y="53" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="70" fill="#ffffaf" textLength="720" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
+13
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#ffffaf" textLength="720" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="19" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#ffffaf" textLength="135" lengthAdjust="spacingAndGlyphs" font-weight="bold">Warning Message</text>
|
||||
<text x="711" y="19" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#ffffaf" textLength="720" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -4,20 +4,25 @@ exports[`Banner > handles newlines in text 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Line 1 │
|
||||
│ Line 2 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`Banner > renders in info mode 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Info Message │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`Banner > renders in multi-line warning 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Title Line │
|
||||
│ Body Line 1 │
|
||||
│ Body Line 2 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`Banner > renders in warning mode 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Warning Message │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -6,11 +6,12 @@ AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 9 │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
│ Line 14 █ │
|
||||
│ Line 15 █ │
|
||||
│ Line 16 █ │
|
||||
│ Line 17 █ │
|
||||
@@ -27,11 +28,12 @@ AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 9 │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
│ Line 14 █ │
|
||||
│ Line 15 █ │
|
||||
│ Line 16 █ │
|
||||
│ Line 17 █ │
|
||||
@@ -47,7 +49,9 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ ... first 11 lines hidden (Ctrl+O to show) ... │
|
||||
│ ... first 9 lines hidden (Ctrl+O to show) ... │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
|
||||
@@ -199,7 +199,7 @@ describe('<ShellToolMessage />', () => {
|
||||
[
|
||||
'uses full availableTerminalHeight when focused in alternate buffer mode',
|
||||
100,
|
||||
98, // 100 - 2
|
||||
98,
|
||||
true,
|
||||
false,
|
||||
],
|
||||
|
||||
@@ -4,16 +4,18 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||
import type {
|
||||
SerializableConfirmationDetails,
|
||||
ToolCallConfirmationDetails,
|
||||
Config,
|
||||
import {
|
||||
type SerializableConfirmationDetails,
|
||||
type ToolCallConfirmationDetails,
|
||||
type Config,
|
||||
ToolConfirmationOutcome,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
import { act } from 'react';
|
||||
|
||||
vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -646,4 +648,63 @@ describe('ToolConfirmationMessage', () => {
|
||||
expect(output).not.toContain('Invocation Arguments:');
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('ESCAPE key behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should call confirm(Cancel) asynchronously via useEffect when ESC is pressed', async () => {
|
||||
const mockConfirm = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(useToolActions).mockReturnValue({
|
||||
confirm: mockConfirm,
|
||||
cancel: vi.fn(),
|
||||
isDiffingEnabled: false,
|
||||
});
|
||||
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'info',
|
||||
title: 'Confirm Web Fetch',
|
||||
prompt: 'https://example.com',
|
||||
urls: ['https://example.com'],
|
||||
};
|
||||
|
||||
const { stdin, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
getPreferredEditor={vi.fn()}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
stdin.write('\x1b');
|
||||
|
||||
// To assert that the confirmation happens asynchronously (via useEffect) rather than
|
||||
// synchronously (directly inside the keystroke handler), we must run our assertion
|
||||
// *inside* the act() block.
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Now that the act() block has returned, React flushes the useEffect, calling handleConfirm.
|
||||
expect(mockConfirm).toHaveBeenCalledWith(
|
||||
'test-call-id',
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
undefined,
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import { useEffect, useMemo, useCallback, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
|
||||
@@ -79,6 +79,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
callId,
|
||||
expanded: false,
|
||||
});
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const isMcpToolDetailsExpanded =
|
||||
mcpDetailsExpansionState.callId === callId
|
||||
? mcpDetailsExpansionState.expanded
|
||||
@@ -183,7 +184,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
handleConfirm(ToolConfirmationOutcome.Cancel);
|
||||
setIsCancelling(true);
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
@@ -196,6 +197,20 @@ export const ToolConfirmationMessage: React.FC<
|
||||
{ isActive: isFocused, priority: true },
|
||||
);
|
||||
|
||||
// TODO(#23009): Remove this hack once we migrate to the new renderer.
|
||||
// Why useEffect is used here instead of calling handleConfirm directly:
|
||||
// There is a race condition where calling handleConfirm immediately upon
|
||||
// keypress removes the tool UI component while the UI is in an expanded state.
|
||||
// This simultaneously triggers setConstrainHeight, causing render two footers.
|
||||
// By bridging the cancel action through state (isCancelling) and this useEffect,
|
||||
// we delay handleConfirm until the next render cycle, ensuring setConstrainHeight
|
||||
// resolves properly first.
|
||||
useEffect(() => {
|
||||
if (isCancelling) {
|
||||
handleConfirm(ToolConfirmationOutcome.Cancel);
|
||||
}
|
||||
}, [isCancelling, handleConfirm]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(item: ToolConfirmationOutcome) => handleConfirm(item),
|
||||
[handleConfirm],
|
||||
|
||||
@@ -46,7 +46,7 @@ export function SlicingMaxSizedBox<T>({
|
||||
text = '...' + text.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
|
||||
}
|
||||
}
|
||||
if (maxLines) {
|
||||
if (maxLines !== undefined) {
|
||||
const hasTrailingNewline = text.endsWith('\n');
|
||||
const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
|
||||
const lines = contentText.split('\n');
|
||||
@@ -71,7 +71,7 @@ export function SlicingMaxSizedBox<T>({
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(data) && !isAlternateBuffer && maxLines) {
|
||||
if (Array.isArray(data) && !isAlternateBuffer && maxLines !== undefined) {
|
||||
if (data.length > maxLines) {
|
||||
// We will have a label from MaxSizedBox. Reserve space for it.
|
||||
const targetLines = Math.max(1, maxLines - 1);
|
||||
|
||||
@@ -75,7 +75,6 @@ export const INFORMATIVE_TIPS = [
|
||||
'Set the character threshold for truncating tool outputs (/settings)…',
|
||||
'Set the number of lines to keep when truncating outputs (/settings)…',
|
||||
'Enable policy-based tool confirmation via message bus (/settings)…',
|
||||
'Enable write_todos_list tool to generate task lists (/settings)…',
|
||||
'Enable experimental subagents for task delegation (/settings)…',
|
||||
'Enable extension management features (settings.json)…',
|
||||
'Enable extension reloading within the CLI session (settings.json)…',
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
calculateToolContentMaxLines,
|
||||
calculateShellMaxLines,
|
||||
SHELL_CONTENT_OVERHEAD,
|
||||
} from './toolLayoutUtils.js';
|
||||
import { CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
import {
|
||||
ACTIVE_SHELL_MAX_LINES,
|
||||
COMPLETED_SHELL_MAX_LINES,
|
||||
} from '../constants.js';
|
||||
|
||||
describe('toolLayoutUtils', () => {
|
||||
describe('calculateToolContentMaxLines', () => {
|
||||
interface CalculateToolContentMaxLinesTestCase {
|
||||
desc: string;
|
||||
options: Parameters<typeof calculateToolContentMaxLines>[0];
|
||||
expected: number | undefined;
|
||||
}
|
||||
|
||||
const testCases: CalculateToolContentMaxLinesTestCase[] = [
|
||||
{
|
||||
desc: 'returns undefined if availableTerminalHeight is undefined',
|
||||
options: {
|
||||
availableTerminalHeight: undefined,
|
||||
isAlternateBuffer: false,
|
||||
},
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
desc: 'returns maxLinesLimit if maxLinesLimit applies but availableTerminalHeight is undefined',
|
||||
options: {
|
||||
availableTerminalHeight: undefined,
|
||||
isAlternateBuffer: false,
|
||||
maxLinesLimit: 10,
|
||||
},
|
||||
expected: 10,
|
||||
},
|
||||
{
|
||||
desc: 'returns available space directly in constrained terminal (Standard mode)',
|
||||
options: {
|
||||
availableTerminalHeight: 2,
|
||||
isAlternateBuffer: false,
|
||||
},
|
||||
expected: 3,
|
||||
},
|
||||
{
|
||||
desc: 'returns available space directly in constrained terminal (ASB mode)',
|
||||
options: {
|
||||
availableTerminalHeight: 4,
|
||||
isAlternateBuffer: true,
|
||||
},
|
||||
expected: 3,
|
||||
},
|
||||
{
|
||||
desc: 'returns remaining space if sufficient space exists (Standard mode)',
|
||||
options: {
|
||||
availableTerminalHeight: 20,
|
||||
isAlternateBuffer: false,
|
||||
},
|
||||
expected: 17,
|
||||
},
|
||||
{
|
||||
desc: 'returns remaining space if sufficient space exists (ASB mode)',
|
||||
options: {
|
||||
availableTerminalHeight: 20,
|
||||
isAlternateBuffer: true,
|
||||
},
|
||||
expected: 13,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)('$desc', ({ options, expected }) => {
|
||||
const result = calculateToolContentMaxLines(options);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateShellMaxLines', () => {
|
||||
interface CalculateShellMaxLinesTestCase {
|
||||
desc: string;
|
||||
options: Parameters<typeof calculateShellMaxLines>[0];
|
||||
expected: number | undefined;
|
||||
}
|
||||
|
||||
const testCases: CalculateShellMaxLinesTestCase[] = [
|
||||
{
|
||||
desc: 'returns undefined when not constrained and is expandable',
|
||||
options: {
|
||||
status: CoreToolCallStatus.Executing,
|
||||
isAlternateBuffer: false,
|
||||
isThisShellFocused: false,
|
||||
availableTerminalHeight: 20,
|
||||
constrainHeight: false,
|
||||
isExpandable: true,
|
||||
},
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
desc: 'returns ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD for ASB mode when availableTerminalHeight is undefined',
|
||||
options: {
|
||||
status: CoreToolCallStatus.Executing,
|
||||
isAlternateBuffer: true,
|
||||
isThisShellFocused: false,
|
||||
availableTerminalHeight: undefined,
|
||||
constrainHeight: true,
|
||||
isExpandable: false,
|
||||
},
|
||||
expected: ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD,
|
||||
},
|
||||
{
|
||||
desc: 'returns undefined for Standard mode when availableTerminalHeight is undefined',
|
||||
options: {
|
||||
status: CoreToolCallStatus.Executing,
|
||||
isAlternateBuffer: false,
|
||||
isThisShellFocused: false,
|
||||
availableTerminalHeight: undefined,
|
||||
constrainHeight: true,
|
||||
isExpandable: false,
|
||||
},
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
desc: 'handles small availableTerminalHeight gracefully without overflow in Standard mode',
|
||||
options: {
|
||||
status: CoreToolCallStatus.Executing,
|
||||
isAlternateBuffer: false,
|
||||
isThisShellFocused: false,
|
||||
availableTerminalHeight: 2,
|
||||
constrainHeight: true,
|
||||
isExpandable: false,
|
||||
},
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
desc: 'handles small availableTerminalHeight gracefully without overflow in ASB mode',
|
||||
options: {
|
||||
status: CoreToolCallStatus.Executing,
|
||||
isAlternateBuffer: true,
|
||||
isThisShellFocused: false,
|
||||
availableTerminalHeight: 6,
|
||||
constrainHeight: true,
|
||||
isExpandable: false,
|
||||
},
|
||||
expected: 4,
|
||||
},
|
||||
{
|
||||
desc: 'handles negative availableTerminalHeight gracefully',
|
||||
options: {
|
||||
status: CoreToolCallStatus.Executing,
|
||||
isAlternateBuffer: false,
|
||||
isThisShellFocused: false,
|
||||
availableTerminalHeight: -5,
|
||||
constrainHeight: true,
|
||||
isExpandable: false,
|
||||
},
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
desc: 'returns maxLinesBasedOnHeight for focused ASB shells',
|
||||
options: {
|
||||
status: CoreToolCallStatus.Executing,
|
||||
isAlternateBuffer: true,
|
||||
isThisShellFocused: true,
|
||||
availableTerminalHeight: 30,
|
||||
constrainHeight: false,
|
||||
isExpandable: false,
|
||||
},
|
||||
expected: 28,
|
||||
},
|
||||
{
|
||||
desc: 'falls back to COMPLETED_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD for completed shells if space allows',
|
||||
options: {
|
||||
status: CoreToolCallStatus.Success,
|
||||
isAlternateBuffer: false,
|
||||
isThisShellFocused: false,
|
||||
availableTerminalHeight: 100,
|
||||
constrainHeight: true,
|
||||
isExpandable: false,
|
||||
},
|
||||
expected: COMPLETED_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD,
|
||||
},
|
||||
{
|
||||
desc: 'falls back to ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD for executing shells if space allows',
|
||||
options: {
|
||||
status: CoreToolCallStatus.Executing,
|
||||
isAlternateBuffer: false,
|
||||
isThisShellFocused: false,
|
||||
availableTerminalHeight: 100,
|
||||
constrainHeight: true,
|
||||
isExpandable: false,
|
||||
},
|
||||
expected: ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)('$desc', ({ options, expected }) => {
|
||||
const result = calculateShellMaxLines(options);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -46,12 +46,13 @@ export function calculateToolContentMaxLines(options: {
|
||||
? TOOL_RESULT_ASB_RESERVED_LINE_COUNT
|
||||
: TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT;
|
||||
|
||||
let contentHeight = availableTerminalHeight
|
||||
? Math.max(
|
||||
availableTerminalHeight - TOOL_RESULT_STATIC_HEIGHT - reservedLines,
|
||||
TOOL_RESULT_MIN_LINES_SHOWN + 1,
|
||||
)
|
||||
: undefined;
|
||||
let contentHeight =
|
||||
availableTerminalHeight !== undefined
|
||||
? Math.max(
|
||||
availableTerminalHeight - TOOL_RESULT_STATIC_HEIGHT - reservedLines,
|
||||
TOOL_RESULT_MIN_LINES_SHOWN + 1,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (maxLinesLimit !== undefined) {
|
||||
contentHeight =
|
||||
@@ -100,7 +101,10 @@ export function calculateShellMaxLines(options: {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2);
|
||||
const maxLinesBasedOnHeight = Math.max(
|
||||
1,
|
||||
availableTerminalHeight - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
|
||||
);
|
||||
|
||||
// 3. Handle ASB mode focus expansion.
|
||||
// We allow a focused shell in ASB mode to take up the full available height,
|
||||
|
||||
@@ -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, '/');
|
||||
|
||||
@@ -44,6 +44,11 @@ vi.mock('../../utils/debugLogger.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock browser consent to always grant consent by default
|
||||
vi.mock('../../utils/browserConsent.js', () => ({
|
||||
getBrowserConsentIfNeeded: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock('./automationOverlay.js', () => ({
|
||||
injectAutomationOverlay: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -64,6 +69,7 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
import * as fs from 'node:fs';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { getBrowserConsentIfNeeded } from '../../utils/browserConsent.js';
|
||||
|
||||
describe('BrowserManager', () => {
|
||||
let mockConfig: Config;
|
||||
@@ -72,6 +78,9 @@ describe('BrowserManager', () => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(injectAutomationOverlay).mockClear();
|
||||
|
||||
// Re-establish consent mock after resetAllMocks
|
||||
vi.mocked(getBrowserConsentIfNeeded).mockResolvedValue(true);
|
||||
|
||||
// Setup mock config
|
||||
mockConfig = makeFakeConfig({
|
||||
agents: {
|
||||
@@ -527,6 +536,41 @@ describe('BrowserManager', () => {
|
||||
/sessionMode: persistent/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass --no-usage-statistics and --no-performance-crux when privacy is disabled', async () => {
|
||||
const privacyDisabledConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
headless: false,
|
||||
},
|
||||
},
|
||||
usageStatisticsEnabled: false,
|
||||
});
|
||||
|
||||
const manager = new BrowserManager(privacyDisabledConfig);
|
||||
await manager.ensureConnection();
|
||||
|
||||
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
|
||||
?.args as string[];
|
||||
expect(args).toContain('--no-usage-statistics');
|
||||
expect(args).toContain('--no-performance-crux');
|
||||
});
|
||||
|
||||
it('should NOT pass privacy flags when usage statistics are enabled', async () => {
|
||||
// Default config has usageStatisticsEnabled: true (or undefined)
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
await manager.ensureConnection();
|
||||
|
||||
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
|
||||
?.args as string[];
|
||||
expect(args).not.toContain('--no-usage-statistics');
|
||||
expect(args).not.toContain('--no-performance-crux');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP isolation', () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import { Storage } from '../../config/storage.js';
|
||||
import { getBrowserConsentIfNeeded } from '../../utils/browserConsent.js';
|
||||
import { injectInputBlocker } from './inputBlocker.js';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
@@ -260,6 +261,16 @@ export class BrowserManager {
|
||||
if (this.rawMcpClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Request browser consent if needed (first-run privacy notice)
|
||||
const consentGranted = await getBrowserConsentIfNeeded();
|
||||
if (!consentGranted) {
|
||||
throw new Error(
|
||||
'Browser agent requires user consent to proceed. ' +
|
||||
'Please re-run and accept the privacy notice.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.connectMcp();
|
||||
}
|
||||
|
||||
@@ -352,6 +363,11 @@ export class BrowserManager {
|
||||
mcpArgs.push('--userDataDir', defaultProfilePath);
|
||||
}
|
||||
|
||||
// Respect the user's privacy.usageStatisticsEnabled setting
|
||||
if (!this.config.getUsageStatisticsEnabled()) {
|
||||
mcpArgs.push('--no-usage-statistics', '--no-performance-crux');
|
||||
}
|
||||
|
||||
if (
|
||||
browserConfig.customConfig.allowedDomains &&
|
||||
browserConfig.customConfig.allowedDomains.length > 0
|
||||
|
||||
@@ -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 {
|
||||
@@ -365,6 +374,76 @@ describe('LocalAgentExecutor', () => {
|
||||
});
|
||||
|
||||
describe('create (Initialization and Validation)', () => {
|
||||
it('should explicitly map execution context properties to prevent unintended propagation', async () => {
|
||||
const definition = createTestDefinition([LS_TOOL_NAME]);
|
||||
const mockGeminiClient =
|
||||
{} as unknown as import('../core/client.js').GeminiClient;
|
||||
const mockSandboxManager =
|
||||
{} as unknown as import('../services/sandboxManager.js').SandboxManager;
|
||||
const extendedContext = {
|
||||
config: mockConfig,
|
||||
promptId: mockConfig.promptId,
|
||||
toolRegistry: parentToolRegistry,
|
||||
promptRegistry: mockConfig.promptRegistry,
|
||||
resourceRegistry: mockConfig.resourceRegistry,
|
||||
messageBus: mockConfig.messageBus,
|
||||
geminiClient: mockGeminiClient,
|
||||
sandboxManager: mockSandboxManager,
|
||||
unintendedProperty: 'should not be here',
|
||||
} as unknown as import('../config/agent-loop-context.js').AgentLoopContext;
|
||||
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
extendedContext,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
mockModelResponse([
|
||||
{
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
args: { finalResult: 'done' },
|
||||
id: 'call1',
|
||||
},
|
||||
]);
|
||||
|
||||
await executor.run({ goal: 'test' }, signal);
|
||||
|
||||
const chatConstructorArgs = MockedGeminiChat.mock.calls[0];
|
||||
const executionContext = chatConstructorArgs[0];
|
||||
|
||||
expect(executionContext).toBeDefined();
|
||||
expect(executionContext.config).toBe(extendedContext.config);
|
||||
expect(executionContext.promptId).toBe(extendedContext.promptId);
|
||||
expect(executionContext.geminiClient).toBe(extendedContext.geminiClient);
|
||||
expect(executionContext.sandboxManager).toBe(
|
||||
extendedContext.sandboxManager,
|
||||
);
|
||||
|
||||
const agentToolRegistry = executor['toolRegistry'];
|
||||
const agentPromptRegistry = executor['promptRegistry'];
|
||||
const agentResourceRegistry = executor['resourceRegistry'];
|
||||
|
||||
expect(executionContext.toolRegistry).toBe(agentToolRegistry);
|
||||
expect(executionContext.promptRegistry).toBe(agentPromptRegistry);
|
||||
expect(executionContext.resourceRegistry).toBe(agentResourceRegistry);
|
||||
|
||||
expect(executionContext.messageBus).toBe(
|
||||
agentToolRegistry.getMessageBus(),
|
||||
);
|
||||
|
||||
// Ensure the unintended property was not spread
|
||||
expect(
|
||||
(executionContext as unknown as { unintendedProperty?: string })
|
||||
.unintendedProperty,
|
||||
).toBeUndefined();
|
||||
|
||||
// Ensure registries and message bus are not the parent's
|
||||
expect(executionContext.toolRegistry).not.toBe(
|
||||
extendedContext.toolRegistry,
|
||||
);
|
||||
expect(executionContext.messageBus).not.toBe(extendedContext.messageBus);
|
||||
});
|
||||
|
||||
it('should create successfully with allowed tools', async () => {
|
||||
const definition = createTestDefinition([LS_TOOL_NAME]);
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
@@ -943,6 +1022,7 @@ describe('LocalAgentExecutor', () => {
|
||||
data: expect.objectContaining({
|
||||
context: 'protocol_violation',
|
||||
error: expectedError,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -988,6 +1068,7 @@ describe('LocalAgentExecutor', () => {
|
||||
context: 'tool_call',
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
error: expectedError,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1091,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,
|
||||
@@ -1109,7 +1190,7 @@ describe('LocalAgentExecutor', () => {
|
||||
],
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
contentLength: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
@@ -1147,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 }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
@@ -1221,6 +1302,7 @@ describe('LocalAgentExecutor', () => {
|
||||
data: expect.objectContaining({
|
||||
context: 'tool_call_unauthorized',
|
||||
name: READ_FILE_TOOL_NAME,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1274,6 +1356,7 @@ describe('LocalAgentExecutor', () => {
|
||||
context: 'tool_call',
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
error: expect.stringContaining('Output validation failed'),
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1320,6 +1403,7 @@ describe('LocalAgentExecutor', () => {
|
||||
type: 'ERROR',
|
||||
data: expect.objectContaining({
|
||||
error: `Error: Failed to create chat object: ${getErrorMessage(initError)}`,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1348,7 +1432,7 @@ describe('LocalAgentExecutor', () => {
|
||||
]);
|
||||
mockScheduleAgentTools.mockResolvedValueOnce([
|
||||
{
|
||||
status: 'error',
|
||||
status: CoreToolCallStatus.Error,
|
||||
request: {
|
||||
callId: 'call1',
|
||||
name: LS_TOOL_NAME,
|
||||
@@ -1399,6 +1483,7 @@ describe('LocalAgentExecutor', () => {
|
||||
context: 'tool_call',
|
||||
name: LS_TOOL_NAME,
|
||||
error: toolErrorMessage,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1421,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', () => {
|
||||
@@ -1615,6 +1851,7 @@ describe('LocalAgentExecutor', () => {
|
||||
data: expect.objectContaining({
|
||||
context: 'timeout',
|
||||
error: 'Agent timed out after 0.5 minutes.',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1803,6 +2040,7 @@ describe('LocalAgentExecutor', () => {
|
||||
data: expect.objectContaining({
|
||||
context: 'recovery_turn',
|
||||
error: 'Graceful recovery attempt failed. Reason: stop',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1886,6 +2124,7 @@ describe('LocalAgentExecutor', () => {
|
||||
data: expect.objectContaining({
|
||||
context: 'recovery_turn',
|
||||
error: 'Graceful recovery attempt failed. Reason: stop',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -2007,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,
|
||||
@@ -113,7 +119,10 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
|
||||
private get executionContext(): AgentLoopContext {
|
||||
return {
|
||||
...this.context,
|
||||
config: this.context.config,
|
||||
promptId: this.context.promptId,
|
||||
geminiClient: this.context.geminiClient,
|
||||
sandboxManager: this.context.sandboxManager,
|
||||
toolRegistry: this.toolRegistry,
|
||||
promptRegistry: this.promptRegistry,
|
||||
resourceRegistry: this.resourceRegistry,
|
||||
@@ -335,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',
|
||||
@@ -468,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) {
|
||||
@@ -475,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 {
|
||||
@@ -680,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
|
||||
@@ -697,6 +711,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
this.emitActivity('ERROR', {
|
||||
error: finalResult,
|
||||
context: 'protocol_violation',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -751,6 +766,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
this.emitActivity('ERROR', {
|
||||
error: finalResult,
|
||||
context: 'timeout',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
return {
|
||||
result: finalResult,
|
||||
@@ -758,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();
|
||||
@@ -1027,6 +1046,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
context: 'tool_call',
|
||||
name: toolName,
|
||||
error,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -1054,6 +1074,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
context: 'tool_call',
|
||||
name: toolName,
|
||||
error,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -1096,6 +1117,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
name: toolName,
|
||||
callId,
|
||||
error,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -1139,6 +1161,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
name: toolName,
|
||||
callId,
|
||||
error,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1163,6 +1186,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
name: toolName,
|
||||
callId,
|
||||
error,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
|
||||
continue;
|
||||
@@ -1210,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]);
|
||||
}
|
||||
}
|
||||
@@ -1332,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';
|
||||
@@ -230,7 +232,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
expect(display.terminateReason).toBe(AgentTerminateMode.TIMEOUT);
|
||||
});
|
||||
|
||||
it('should stream THOUGHT_CHUNK activities from the executor', async () => {
|
||||
it('should stream THOUGHT_CHUNK activities from the executor, replacing the last running thought', async () => {
|
||||
mockExecutorInstance.run.mockImplementation(async () => {
|
||||
const onActivity = MockLocalAgentExecutor.create.mock.calls[0][2];
|
||||
|
||||
@@ -245,7 +247,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'THOUGHT_CHUNK',
|
||||
data: { text: ' Still thinking.' },
|
||||
data: { text: 'Thinking about next steps.' },
|
||||
} as SubagentActivityEvent);
|
||||
}
|
||||
return { result: 'Done', terminate_reason: AgentTerminateMode.GOAL };
|
||||
@@ -258,7 +260,13 @@ describe('LocalSubagentInvocation', () => {
|
||||
expect(lastCall.recentActivity).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'thought',
|
||||
content: 'Analyzing... Still thinking.',
|
||||
content: 'Thinking about next steps.',
|
||||
}),
|
||||
);
|
||||
expect(lastCall.recentActivity).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'thought',
|
||||
content: 'Analyzing...',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -297,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';
|
||||
@@ -120,7 +123,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
lastItem.type === 'thought' &&
|
||||
lastItem.status === 'running'
|
||||
) {
|
||||
lastItem.content += text;
|
||||
lastItem.content = text;
|
||||
} else {
|
||||
recentActivity.push({
|
||||
id: randomUUID(),
|
||||
@@ -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;
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
toolName = "enter_plan_mode"
|
||||
decision = "ask_user"
|
||||
priority = 50
|
||||
interactive = true
|
||||
|
||||
[[rule]]
|
||||
toolName = "enter_plan_mode"
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
interactive = false
|
||||
|
||||
[[rule]]
|
||||
toolName = "enter_plan_mode"
|
||||
@@ -46,6 +53,13 @@ toolName = "exit_plan_mode"
|
||||
decision = "ask_user"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
interactive = true
|
||||
|
||||
[[rule]]
|
||||
toolName = "exit_plan_mode"
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
interactive = false
|
||||
|
||||
[[rule]]
|
||||
toolName = "exit_plan_mode"
|
||||
|
||||
@@ -45,6 +45,7 @@ toolName = ["enter_plan_mode", "exit_plan_mode"]
|
||||
decision = "deny"
|
||||
priority = 999
|
||||
modes = ["yolo"]
|
||||
interactive = true
|
||||
|
||||
# Allow everything else in YOLO mode
|
||||
[[rule]]
|
||||
|
||||
@@ -3343,4 +3343,121 @@ describe('PolicyEngine', () => {
|
||||
expect(excluded.has('test-tool')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactive matching', () => {
|
||||
it('should ignore interactive rules in non-interactive mode', async () => {
|
||||
const engine = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolName: 'my_tool',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
interactive: true,
|
||||
},
|
||||
],
|
||||
nonInteractive: true,
|
||||
defaultDecision: PolicyDecision.DENY,
|
||||
});
|
||||
|
||||
const result = await engine.check(
|
||||
{ name: 'my_tool', args: {} },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should allow interactive rules in interactive mode', async () => {
|
||||
const engine = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolName: 'my_tool',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
interactive: true,
|
||||
},
|
||||
],
|
||||
nonInteractive: false,
|
||||
defaultDecision: PolicyDecision.DENY,
|
||||
});
|
||||
|
||||
const result = await engine.check(
|
||||
{ name: 'my_tool', args: {} },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should ignore non-interactive rules in interactive mode', async () => {
|
||||
const engine = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolName: 'my_tool',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
interactive: false,
|
||||
},
|
||||
],
|
||||
nonInteractive: false,
|
||||
defaultDecision: PolicyDecision.DENY,
|
||||
});
|
||||
|
||||
const result = await engine.check(
|
||||
{ name: 'my_tool', args: {} },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should allow non-interactive rules in non-interactive mode', async () => {
|
||||
const engine = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolName: 'my_tool',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
interactive: false,
|
||||
},
|
||||
],
|
||||
nonInteractive: true,
|
||||
defaultDecision: PolicyDecision.DENY,
|
||||
});
|
||||
|
||||
const result = await engine.check(
|
||||
{ name: 'my_tool', args: {} },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should apply rules without interactive flag to both', async () => {
|
||||
const rule: PolicyRule = {
|
||||
toolName: 'my_tool',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
};
|
||||
|
||||
const engineInteractive = new PolicyEngine({
|
||||
rules: [rule],
|
||||
nonInteractive: false,
|
||||
defaultDecision: PolicyDecision.DENY,
|
||||
});
|
||||
const engineNonInteractive = new PolicyEngine({
|
||||
rules: [rule],
|
||||
nonInteractive: true,
|
||||
defaultDecision: PolicyDecision.DENY,
|
||||
});
|
||||
|
||||
expect(
|
||||
(
|
||||
await engineInteractive.check(
|
||||
{ name: 'my_tool', args: {} },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
expect(
|
||||
(
|
||||
await engineNonInteractive.check(
|
||||
{ name: 'my_tool', args: {} },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,7 @@ function ruleMatches(
|
||||
stringifiedArgs: string | undefined,
|
||||
serverName: string | undefined,
|
||||
currentApprovalMode: ApprovalMode,
|
||||
nonInteractive: boolean,
|
||||
toolAnnotations?: Record<string, unknown>,
|
||||
subagent?: string,
|
||||
): boolean {
|
||||
@@ -146,6 +147,16 @@ function ruleMatches(
|
||||
}
|
||||
}
|
||||
|
||||
// Check interactive if specified
|
||||
if ('interactive' in rule && rule.interactive !== undefined) {
|
||||
if (rule.interactive && nonInteractive) {
|
||||
return false;
|
||||
}
|
||||
if (!rule.interactive && !nonInteractive) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -443,6 +454,7 @@ export class PolicyEngine {
|
||||
stringifiedArgs,
|
||||
serverName,
|
||||
this.approvalMode,
|
||||
this.nonInteractive,
|
||||
toolAnnotations,
|
||||
subagent,
|
||||
),
|
||||
@@ -521,6 +533,7 @@ export class PolicyEngine {
|
||||
stringifiedArgs,
|
||||
serverName,
|
||||
this.approvalMode,
|
||||
this.nonInteractive,
|
||||
toolAnnotations,
|
||||
subagent,
|
||||
)
|
||||
@@ -713,6 +726,7 @@ export class PolicyEngine {
|
||||
undefined, // stringifiedArgs
|
||||
serverName,
|
||||
this.approvalMode,
|
||||
this.nonInteractive,
|
||||
annotations,
|
||||
);
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ const PolicyRuleSchema = z.object({
|
||||
'priority must be <= 999 to prevent tier overflow. Priorities >= 1000 would jump to the next tier.',
|
||||
}),
|
||||
modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
|
||||
interactive: z.boolean().optional(),
|
||||
toolAnnotations: z.record(z.any()).optional(),
|
||||
allow_redirection: z.boolean().optional(),
|
||||
deny_message: z.string().optional(),
|
||||
@@ -475,6 +476,7 @@ export async function loadPoliciesFromToml(
|
||||
decision: rule.decision,
|
||||
priority: transformPriority(rule.priority, tier),
|
||||
modes: rule.modes,
|
||||
interactive: rule.interactive,
|
||||
toolAnnotations: rule.toolAnnotations,
|
||||
allowRedirection: rule.allow_redirection,
|
||||
source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`,
|
||||
|
||||
@@ -152,6 +152,13 @@ export interface PolicyRule {
|
||||
*/
|
||||
modes?: ApprovalMode[];
|
||||
|
||||
/**
|
||||
* If true, this rule only applies to interactive environments.
|
||||
* If false, this rule only applies to non-interactive environments.
|
||||
* If undefined, it applies to both interactive and non-interactive environments.
|
||||
*/
|
||||
interactive?: boolean;
|
||||
|
||||
/**
|
||||
* If true, allows command redirection even if the policy engine would normally
|
||||
* downgrade ALLOW to ASK_USER for redirected commands.
|
||||
|
||||
@@ -175,6 +175,7 @@ export class PromptProvider {
|
||||
planningWorkflow: this.withSection(
|
||||
'planningWorkflow',
|
||||
() => ({
|
||||
interactive: interactiveMode,
|
||||
planModeToolsList,
|
||||
plansDir: context.config.storage.getPlansDir(),
|
||||
approvedPlanPath: context.config.getApprovedPlanPath(),
|
||||
|
||||
@@ -88,6 +88,7 @@ export interface GitRepoOptions {
|
||||
}
|
||||
|
||||
export interface PlanningWorkflowOptions {
|
||||
interactive: boolean;
|
||||
planModeToolsList: string;
|
||||
plansDir: string;
|
||||
approvedPlanPath?: string;
|
||||
@@ -513,7 +514,7 @@ export function renderPlanningWorkflow(
|
||||
return `
|
||||
# Active Approval Mode: Plan
|
||||
|
||||
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`${options.plansDir}/\` and get user approval before editing source code.
|
||||
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`${options.plansDir}/\` and ${options.interactive ? 'get user approval before editing source code.' : 'create a design document before proceeding autonomously.'}
|
||||
|
||||
## Available Tools
|
||||
The following tools are available in Plan Mode:
|
||||
@@ -550,7 +551,7 @@ Write the implementation plan to \`${options.plansDir}/\`. The plan's structure
|
||||
- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies.
|
||||
|
||||
### 4. Review & Approval
|
||||
Use the ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} tool to present the plan and formally request approval.
|
||||
Use the ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} tool to present the plan and ${options.interactive ? 'formally request approval.' : 'begin implementation.'}
|
||||
|
||||
${renderApprovedPlanSection(options.approvedPlanPath)}`.trim();
|
||||
}
|
||||
@@ -711,7 +712,7 @@ function newApplicationSteps(options: PrimaryWorkflowsOptions): string {
|
||||
// standard 'Execution' loop handle implementation once the plan is approved.
|
||||
if (options.enableEnterPlanModeTool) {
|
||||
return `
|
||||
1. **Mandatory Planning:** You MUST use the ${formatToolName(ENTER_PLAN_MODE_TOOL_NAME)} tool to draft a comprehensive design document and obtain user approval before writing any code.
|
||||
1. **Mandatory Planning:** You MUST use the ${formatToolName(ENTER_PLAN_MODE_TOOL_NAME)} tool to draft a comprehensive design document${options.interactive ? ' and obtain user approval' : ''} before writing any code.
|
||||
2. **Design Constraints:** When drafting your plan, adhere to these defaults unless explicitly overridden by the user:
|
||||
- **Goal:** Autonomously design a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, typography, and interactive feedback.
|
||||
- **Visuals:** Describe your strategy for sourcing or generating placeholders (e.g., stylized CSS shapes, gradients, procedurally generated patterns) to ensure a visually complete prototype. Never plan for assets that cannot be locally generated.
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { MacOsSandboxManager } from './MacOsSandboxManager.js';
|
||||
import { ShellExecutionService } from '../../services/shellExecutionService.js';
|
||||
import { getSecureSanitizationConfig } from '../../services/environmentSanitization.js';
|
||||
import { type SandboxedCommand } from '../../services/sandboxManager.js';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
|
||||
/**
|
||||
* A simple asynchronous wrapper for execFile that returns the exit status,
|
||||
* stdout, and stderr. Unlike spawnSync, this does not block the Node.js
|
||||
* event loop, allowing the local HTTP test server to function.
|
||||
*/
|
||||
async function runCommand(command: SandboxedCommand) {
|
||||
try {
|
||||
const { stdout, stderr } = await promisify(execFile)(
|
||||
command.program,
|
||||
command.args,
|
||||
{
|
||||
cwd: command.cwd,
|
||||
env: command.env,
|
||||
encoding: 'utf-8',
|
||||
},
|
||||
);
|
||||
return { status: 0, stdout, stderr };
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
code?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
return {
|
||||
status: err.code ?? 1,
|
||||
stdout: err.stdout ?? '',
|
||||
stderr: err.stderr ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(os.platform() !== 'darwin')(
|
||||
'MacOsSandboxManager Integration',
|
||||
() => {
|
||||
describe('Basic Execution', () => {
|
||||
it('should execute commands within the workspace', async () => {
|
||||
const manager = new MacOsSandboxManager({ workspace: process.cwd() });
|
||||
const command = await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['sandbox test'],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const execResult = await runCommand(command);
|
||||
|
||||
expect(execResult.status).toBe(0);
|
||||
expect(execResult.stdout.trim()).toBe('sandbox test');
|
||||
});
|
||||
|
||||
it('should support interactive pseudo-terminals (node-pty)', async () => {
|
||||
const manager = new MacOsSandboxManager({ workspace: process.cwd() });
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Verify that node-pty file descriptors are successfully allocated inside the sandbox
|
||||
// by using the bash [ -t 1 ] idiom to check if stdout is a TTY.
|
||||
const handle = await ShellExecutionService.execute(
|
||||
'bash -c "if [ -t 1 ]; then echo True; else echo False; fi"',
|
||||
process.cwd(),
|
||||
() => {},
|
||||
abortController.signal,
|
||||
true,
|
||||
{
|
||||
sanitizationConfig: getSecureSanitizationConfig(),
|
||||
sandboxManager: manager,
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handle.result;
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toContain('True');
|
||||
});
|
||||
});
|
||||
|
||||
describe('File System Access', () => {
|
||||
it('should block file system access outside the workspace', async () => {
|
||||
const manager = new MacOsSandboxManager({ workspace: process.cwd() });
|
||||
const blockedPath = '/Users/Shared/.gemini_test_sandbox_blocked';
|
||||
|
||||
const command = await manager.prepareCommand({
|
||||
command: 'touch',
|
||||
args: [blockedPath],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
const execResult = await runCommand(command);
|
||||
|
||||
expect(execResult.status).not.toBe(0);
|
||||
expect(execResult.stderr).toContain('Operation not permitted');
|
||||
});
|
||||
|
||||
it('should grant file system access to explicitly allowed paths', async () => {
|
||||
// Create a unique temporary directory to prevent artifacts and test flakiness
|
||||
const allowedDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-sandbox-test-'),
|
||||
);
|
||||
|
||||
try {
|
||||
const manager = new MacOsSandboxManager({
|
||||
workspace: process.cwd(),
|
||||
allowedPaths: [allowedDir],
|
||||
});
|
||||
const testFile = path.join(allowedDir, 'test.txt');
|
||||
|
||||
const command = await manager.prepareCommand({
|
||||
command: 'touch',
|
||||
args: [testFile],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const execResult = await runCommand(command);
|
||||
|
||||
expect(execResult.status).toBe(0);
|
||||
} finally {
|
||||
fs.rmSync(allowedDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Network Access', () => {
|
||||
let testServer: http.Server;
|
||||
let testServerUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testServer = http.createServer((_, res) => {
|
||||
// Ensure connections are closed immediately to prevent hanging
|
||||
res.setHeader('Connection', 'close');
|
||||
res.writeHead(200);
|
||||
res.end('ok');
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
testServer.on('error', reject);
|
||||
testServer.listen(0, '127.0.0.1', () => {
|
||||
const address = testServer.address() as import('net').AddressInfo;
|
||||
testServerUrl = `http://127.0.0.1:${address.port}`;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (testServer) {
|
||||
await new Promise<void>((resolve) => {
|
||||
testServer.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should block network access by default', async () => {
|
||||
const manager = new MacOsSandboxManager({ workspace: process.cwd() });
|
||||
const command = await manager.prepareCommand({
|
||||
command: 'curl',
|
||||
args: ['-s', '--connect-timeout', '1', testServerUrl],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const execResult = await runCommand(command);
|
||||
|
||||
expect(execResult.status).not.toBe(0);
|
||||
});
|
||||
|
||||
it('should grant network access when explicitly allowed', async () => {
|
||||
const manager = new MacOsSandboxManager({
|
||||
workspace: process.cwd(),
|
||||
networkAccess: true,
|
||||
});
|
||||
const command = await manager.prepareCommand({
|
||||
command: 'curl',
|
||||
args: ['-s', '--connect-timeout', '1', testServerUrl],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const execResult = await runCommand(command);
|
||||
|
||||
expect(execResult.status).toBe(0);
|
||||
expect(execResult.stdout.trim()).toBe('ok');
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { MacOsSandboxManager } from './MacOsSandboxManager.js';
|
||||
import * as seatbeltArgsBuilder from './seatbeltArgsBuilder.js';
|
||||
|
||||
describe('MacOsSandboxManager', () => {
|
||||
const mockWorkspace = '/test/workspace';
|
||||
const mockAllowedPaths = ['/test/allowed'];
|
||||
const mockNetworkAccess = true;
|
||||
|
||||
let manager: MacOsSandboxManager;
|
||||
let buildArgsSpy: MockInstance<typeof seatbeltArgsBuilder.buildSeatbeltArgs>;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new MacOsSandboxManager({
|
||||
workspace: mockWorkspace,
|
||||
allowedPaths: mockAllowedPaths,
|
||||
networkAccess: mockNetworkAccess,
|
||||
});
|
||||
|
||||
buildArgsSpy = vi
|
||||
.spyOn(seatbeltArgsBuilder, 'buildSeatbeltArgs')
|
||||
.mockReturnValue([
|
||||
'-p',
|
||||
'(mock profile)',
|
||||
'-D',
|
||||
'WORKSPACE=/test/workspace',
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should correctly invoke buildSeatbeltArgs with the configured options', async () => {
|
||||
await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: mockWorkspace,
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(buildArgsSpy).toHaveBeenCalledWith({
|
||||
workspace: mockWorkspace,
|
||||
allowedPaths: mockAllowedPaths,
|
||||
networkAccess: mockNetworkAccess,
|
||||
});
|
||||
});
|
||||
|
||||
it('should format the executable and arguments correctly for sandbox-exec', async () => {
|
||||
const result = await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: mockWorkspace,
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.program).toBe('/usr/bin/sandbox-exec');
|
||||
expect(result.args).toEqual([
|
||||
'-p',
|
||||
'(mock profile)',
|
||||
'-D',
|
||||
'WORKSPACE=/test/workspace',
|
||||
'--',
|
||||
'echo',
|
||||
'hello',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should correctly pass through the cwd to the resulting command', async () => {
|
||||
const result = await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: '/test/different/cwd',
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.cwd).toBe('/test/different/cwd');
|
||||
});
|
||||
|
||||
it('should apply environment sanitization via the default mechanisms', async () => {
|
||||
const result = await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: mockWorkspace,
|
||||
env: {
|
||||
SAFE_VAR: '1',
|
||||
GITHUB_TOKEN: 'sensitive',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.env['SAFE_VAR']).toBe('1');
|
||||
expect(result.env['GITHUB_TOKEN']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type SandboxManager,
|
||||
type SandboxRequest,
|
||||
type SandboxedCommand,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
getSecureSanitizationConfig,
|
||||
type EnvironmentSanitizationConfig,
|
||||
} from '../../services/environmentSanitization.js';
|
||||
import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js';
|
||||
|
||||
/**
|
||||
* Options for configuring the MacOsSandboxManager.
|
||||
*/
|
||||
export interface MacOsSandboxOptions {
|
||||
/** The primary workspace path to allow access to within the sandbox. */
|
||||
workspace: string;
|
||||
/** Additional paths to allow access to within the sandbox. */
|
||||
allowedPaths?: string[];
|
||||
/** Whether network access is allowed. */
|
||||
networkAccess?: boolean;
|
||||
/** Optional base sanitization config. */
|
||||
sanitizationConfig?: EnvironmentSanitizationConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* A SandboxManager implementation for macOS that uses Seatbelt.
|
||||
*/
|
||||
export class MacOsSandboxManager implements SandboxManager {
|
||||
constructor(private readonly options: MacOsSandboxOptions) {}
|
||||
|
||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
const sanitizationConfig = getSecureSanitizationConfig(
|
||||
req.config?.sanitizationConfig,
|
||||
this.options.sanitizationConfig,
|
||||
);
|
||||
|
||||
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
|
||||
|
||||
const sandboxArgs = buildSeatbeltArgs({
|
||||
workspace: this.options.workspace,
|
||||
allowedPaths: this.options.allowedPaths,
|
||||
networkAccess: this.options.networkAccess,
|
||||
});
|
||||
|
||||
return {
|
||||
program: '/usr/bin/sandbox-exec',
|
||||
args: [...sandboxArgs, '--', req.command, ...req.args],
|
||||
env: sanitizedEnv,
|
||||
cwd: req.cwd,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* The base macOS Seatbelt (SBPL) profile for tool execution.
|
||||
*
|
||||
* This uses a strict allowlist (deny default) but imports Apple's base system profile
|
||||
* to handle undocumented internal dependencies, sysctls, and IPC mach ports required
|
||||
* by standard tools to avoid "Abort trap: 6".
|
||||
*/
|
||||
export const BASE_SEATBELT_PROFILE = `(version 1)
|
||||
(deny default)
|
||||
|
||||
(import "system.sb")
|
||||
|
||||
; Core execution requirements
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
(allow signal (target same-sandbox))
|
||||
(allow process-info* (target same-sandbox))
|
||||
|
||||
; Allow basic read access to system frameworks and libraries required to run
|
||||
(allow file-read*
|
||||
(subpath "/System")
|
||||
(subpath "/usr/lib")
|
||||
(subpath "/usr/share")
|
||||
(subpath "/usr/bin")
|
||||
(subpath "/bin")
|
||||
(subpath "/sbin")
|
||||
(subpath "/usr/local/bin")
|
||||
(subpath "/opt/homebrew")
|
||||
(subpath "/Library")
|
||||
(subpath "/private/var/run")
|
||||
(subpath "/private/var/db")
|
||||
(subpath "/private/etc")
|
||||
)
|
||||
|
||||
; PTY and Terminal support
|
||||
(allow pseudo-tty)
|
||||
(allow file-read* file-write* file-ioctl (literal "/dev/ptmx"))
|
||||
(allow file-read* file-write* file-ioctl (regex #"^/dev/ttys[0-9]+"))
|
||||
|
||||
; Allow read/write access to temporary directories and common device nodes
|
||||
(allow file-read* file-write*
|
||||
(literal "/dev/null")
|
||||
(literal "/dev/zero")
|
||||
(subpath "/tmp")
|
||||
(subpath "/private/tmp")
|
||||
(subpath (param "TMPDIR"))
|
||||
)
|
||||
|
||||
; Workspace access using parameterized paths
|
||||
(allow file-read* file-write*
|
||||
(subpath (param "WORKSPACE"))
|
||||
)
|
||||
`;
|
||||
|
||||
/**
|
||||
* The network-specific macOS Seatbelt (SBPL) profile rules.
|
||||
*
|
||||
* These rules are appended to the base profile when network access is enabled,
|
||||
* allowing standard socket creation, DNS resolution, and TLS certificate validation.
|
||||
*/
|
||||
export const NETWORK_SEATBELT_PROFILE = `
|
||||
; Network Access
|
||||
(allow network*)
|
||||
|
||||
(allow system-socket
|
||||
(require-all
|
||||
(socket-domain AF_SYSTEM)
|
||||
(socket-protocol 2)
|
||||
)
|
||||
)
|
||||
|
||||
(allow mach-lookup
|
||||
(global-name "com.apple.bsd.dirhelper")
|
||||
(global-name "com.apple.system.opendirectoryd.membership")
|
||||
(global-name "com.apple.SecurityServer")
|
||||
(global-name "com.apple.networkd")
|
||||
(global-name "com.apple.ocspd")
|
||||
(global-name "com.apple.trustd.agent")
|
||||
(global-name "com.apple.mDNSResponder")
|
||||
(global-name "com.apple.mDNSResponderHelper")
|
||||
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
|
||||
(global-name "com.apple.SystemConfiguration.configd")
|
||||
)
|
||||
|
||||
(allow sysctl-read
|
||||
(sysctl-name-regex #"^net.routetable")
|
||||
)
|
||||
`;
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
|
||||
describe('seatbeltArgsBuilder', () => {
|
||||
it('should build a strict allowlist profile allowing the workspace via param', () => {
|
||||
// Mock realpathSync to just return the path for testing
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => p as string);
|
||||
|
||||
const args = buildSeatbeltArgs({ workspace: '/Users/test/workspace' });
|
||||
|
||||
expect(args[0]).toBe('-p');
|
||||
const profile = args[1];
|
||||
expect(profile).toContain('(version 1)');
|
||||
expect(profile).toContain('(deny default)');
|
||||
expect(profile).toContain('(allow process-exec)');
|
||||
expect(profile).toContain('(subpath (param "WORKSPACE"))');
|
||||
expect(profile).not.toContain('(allow network*)');
|
||||
|
||||
expect(args).toContain('-D');
|
||||
expect(args).toContain('WORKSPACE=/Users/test/workspace');
|
||||
expect(args).toContain(`TMPDIR=${os.tmpdir()}`);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should allow network when networkAccess is true', () => {
|
||||
const args = buildSeatbeltArgs({ workspace: '/test', networkAccess: true });
|
||||
const profile = args[1];
|
||||
expect(profile).toContain('(allow network*)');
|
||||
});
|
||||
|
||||
it('should parameterize allowed paths and normalize them', () => {
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
|
||||
if (p === '/test/symlink') return '/test/real_path';
|
||||
return p as string;
|
||||
});
|
||||
|
||||
const args = buildSeatbeltArgs({
|
||||
workspace: '/test',
|
||||
allowedPaths: ['/custom/path1', '/test/symlink'],
|
||||
});
|
||||
|
||||
const profile = args[1];
|
||||
expect(profile).toContain('(subpath (param "ALLOWED_PATH_0"))');
|
||||
expect(profile).toContain('(subpath (param "ALLOWED_PATH_1"))');
|
||||
|
||||
expect(args).toContain('-D');
|
||||
expect(args).toContain('ALLOWED_PATH_0=/custom/path1');
|
||||
expect(args).toContain('ALLOWED_PATH_1=/test/real_path');
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should resolve parent directories if a file does not exist', () => {
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
|
||||
if (p === '/test/symlink/nonexistent.txt') {
|
||||
const error = new Error('ENOENT');
|
||||
Object.assign(error, { code: 'ENOENT' });
|
||||
throw error;
|
||||
}
|
||||
if (p === '/test/symlink') {
|
||||
return '/test/real_path';
|
||||
}
|
||||
return p as string;
|
||||
});
|
||||
|
||||
const args = buildSeatbeltArgs({
|
||||
workspace: '/test/symlink/nonexistent.txt',
|
||||
});
|
||||
|
||||
expect(args).toContain('WORKSPACE=/test/real_path/nonexistent.txt');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should throw if realpathSync throws a non-ENOENT error', () => {
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation(() => {
|
||||
const error = new Error('Permission denied');
|
||||
Object.assign(error, { code: 'EACCES' });
|
||||
throw error;
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
buildSeatbeltArgs({
|
||||
workspace: '/test/workspace',
|
||||
}),
|
||||
).toThrow('Permission denied');
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
BASE_SEATBELT_PROFILE,
|
||||
NETWORK_SEATBELT_PROFILE,
|
||||
} from './baseProfile.js';
|
||||
|
||||
/**
|
||||
* Options for building macOS Seatbelt arguments.
|
||||
*/
|
||||
export interface SeatbeltArgsOptions {
|
||||
/** The primary workspace path to allow access to. */
|
||||
workspace: string;
|
||||
/** Additional paths to allow access to. */
|
||||
allowedPaths?: string[];
|
||||
/** Whether to allow network access. */
|
||||
networkAccess?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves symlinks for a given path to prevent sandbox escapes.
|
||||
* If a file does not exist (ENOENT), it recursively resolves the parent directory.
|
||||
* Other errors (e.g. EACCES) are re-thrown.
|
||||
*/
|
||||
function tryRealpath(p: string): string {
|
||||
try {
|
||||
return fs.realpathSync(p);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
|
||||
const parentDir = path.dirname(p);
|
||||
if (parentDir === p) {
|
||||
return p;
|
||||
}
|
||||
return path.join(tryRealpath(parentDir), path.basename(p));
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the arguments array for sandbox-exec using a strict allowlist profile.
|
||||
* It relies on parameters passed to sandbox-exec via the -D flag to avoid
|
||||
* string interpolation vulnerabilities, and normalizes paths against symlink escapes.
|
||||
*
|
||||
* Returns arguments up to the end of sandbox-exec configuration (e.g. ['-p', '<profile>', '-D', ...])
|
||||
* Does not include the final '--' separator or the command to run.
|
||||
*/
|
||||
export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] {
|
||||
let profile = BASE_SEATBELT_PROFILE + '\n';
|
||||
const args: string[] = [];
|
||||
|
||||
const workspacePath = tryRealpath(options.workspace);
|
||||
args.push('-D', `WORKSPACE=${workspacePath}`);
|
||||
|
||||
const tmpPath = tryRealpath(os.tmpdir());
|
||||
args.push('-D', `TMPDIR=${tmpPath}`);
|
||||
|
||||
if (options.allowedPaths) {
|
||||
for (let i = 0; i < options.allowedPaths.length; i++) {
|
||||
const allowedPath = tryRealpath(options.allowedPaths[i]);
|
||||
args.push('-D', `ALLOWED_PATH_${i}=${allowedPath}`);
|
||||
profile += `(allow file-read* file-write* (subpath (param "ALLOWED_PATH_${i}")))\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.networkAccess) {
|
||||
profile += NETWORK_SEATBELT_PROFILE;
|
||||
}
|
||||
|
||||
args.unshift('-p', profile);
|
||||
|
||||
return args;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createSandboxManager,
|
||||
} from './sandboxManager.js';
|
||||
import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js';
|
||||
import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js';
|
||||
|
||||
describe('NoopSandboxManager', () => {
|
||||
const sandboxManager = new NoopSandboxManager();
|
||||
@@ -124,23 +125,20 @@ describe('createSandboxManager', () => {
|
||||
expect(manager).toBeInstanceOf(NoopSandboxManager);
|
||||
});
|
||||
|
||||
it('should return LinuxSandboxManager if sandboxing is enabled and platform is linux', () => {
|
||||
const osSpy = vi.spyOn(os, 'platform').mockReturnValue('linux');
|
||||
try {
|
||||
const manager = createSandboxManager(true, '/workspace');
|
||||
expect(manager).toBeInstanceOf(LinuxSandboxManager);
|
||||
} finally {
|
||||
osSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return LocalSandboxManager if sandboxing is enabled and platform is not linux', () => {
|
||||
const osSpy = vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
||||
try {
|
||||
const manager = createSandboxManager(true, '/workspace');
|
||||
expect(manager).toBeInstanceOf(LocalSandboxManager);
|
||||
} finally {
|
||||
osSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
it.each([
|
||||
{ platform: 'linux', expected: LinuxSandboxManager },
|
||||
{ platform: 'darwin', expected: MacOsSandboxManager },
|
||||
{ platform: 'win32', expected: LocalSandboxManager },
|
||||
] as const)(
|
||||
'should return $expected.name if sandboxing is enabled and platform is $platform',
|
||||
({ platform, expected }) => {
|
||||
const osSpy = vi.spyOn(os, 'platform').mockReturnValue(platform);
|
||||
try {
|
||||
const manager = createSandboxManager(true, '/workspace');
|
||||
expect(manager).toBeInstanceOf(expected);
|
||||
} finally {
|
||||
osSpy.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type EnvironmentSanitizationConfig,
|
||||
} from './environmentSanitization.js';
|
||||
import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js';
|
||||
import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js';
|
||||
|
||||
/**
|
||||
* Request for preparing a command to run in a sandbox.
|
||||
@@ -98,6 +99,9 @@ export function createSandboxManager(
|
||||
if (os.platform() === 'linux') {
|
||||
return new LinuxSandboxManager({ workspace });
|
||||
}
|
||||
if (os.platform() === 'darwin') {
|
||||
return new MacOsSandboxManager({ workspace });
|
||||
}
|
||||
return new LocalSandboxManager();
|
||||
}
|
||||
return new NoopSandboxManager();
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('ExitPlanModeTool', () => {
|
||||
storage: {
|
||||
getPlansDir: vi.fn().mockReturnValue(mockPlansDir),
|
||||
} as unknown as Config['storage'],
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
tool = new ExitPlanModeTool(
|
||||
mockConfig as Config,
|
||||
@@ -359,6 +360,36 @@ Ask the user for specific feedback on how to improve the plan.`,
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllowApprovalMode (internal)', () => {
|
||||
it('should return YOLO when config.isInteractive() is false', async () => {
|
||||
mockConfig.isInteractive = vi.fn().mockReturnValue(false);
|
||||
const planRelativePath = createPlanFile('test.md', '# Content');
|
||||
const invocation = tool.build({ plan_path: planRelativePath });
|
||||
|
||||
// Directly call execute to trigger the internal getAllowApprovalMode
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('YOLO mode');
|
||||
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
|
||||
ApprovalMode.YOLO,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return DEFAULT when config.isInteractive() is true', async () => {
|
||||
mockConfig.isInteractive = vi.fn().mockReturnValue(true);
|
||||
const planRelativePath = createPlanFile('test.md', '# Content');
|
||||
const invocation = tool.build({ plan_path: planRelativePath });
|
||||
|
||||
// Directly call execute to trigger the internal getAllowApprovalMode
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('Default mode');
|
||||
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getApprovalModeDescription (internal)', () => {
|
||||
it('should handle all valid approval modes', async () => {
|
||||
const planRelativePath = createPlanFile('test.md', '# Content');
|
||||
@@ -387,6 +418,10 @@ Ask the user for specific feedback on how to improve the plan.`,
|
||||
ApprovalMode.DEFAULT,
|
||||
'Default mode (edits will require confirmation)',
|
||||
);
|
||||
await testMode(
|
||||
ApprovalMode.YOLO,
|
||||
'YOLO mode (all tool calls auto-approved)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw for invalid post-planning modes', async () => {
|
||||
@@ -409,7 +444,6 @@ Ask the user for specific feedback on how to improve the plan.`,
|
||||
).rejects.toThrow(/Unexpected approval mode/);
|
||||
};
|
||||
|
||||
await testInvalidMode(ApprovalMode.YOLO);
|
||||
await testInvalidMode(ApprovalMode.PLAN);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
type ToolResult,
|
||||
Kind,
|
||||
type ToolExitPlanModeConfirmationDetails,
|
||||
type ToolConfirmationPayload,
|
||||
type ToolExitPlanModeConfirmationPayload,
|
||||
ToolConfirmationOutcome,
|
||||
type ToolConfirmationPayload,
|
||||
type ToolExitPlanModeConfirmationDetails,
|
||||
type ToolExitPlanModeConfirmationPayload,
|
||||
type ToolResult,
|
||||
} from './tools.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import path from 'node:path';
|
||||
@@ -151,7 +151,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
this.confirmationOutcome = ToolConfirmationOutcome.ProceedOnce;
|
||||
this.approvalPayload = {
|
||||
approved: true,
|
||||
approvalMode: ApprovalMode.DEFAULT,
|
||||
approvalMode: this.getAllowApprovalMode(),
|
||||
};
|
||||
return false;
|
||||
}
|
||||
@@ -205,17 +205,15 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
|
||||
// When a user policy grants `allow` for exit_plan_mode, the scheduler
|
||||
// skips the confirmation phase entirely and shouldConfirmExecute is never
|
||||
// called, leaving approvalPayload null. Treat that as an approval with
|
||||
// the default mode — consistent with the ALLOW branch inside
|
||||
// shouldConfirmExecute.
|
||||
// called, leaving approvalPayload null.
|
||||
const payload = this.approvalPayload ?? {
|
||||
approved: true,
|
||||
approvalMode: ApprovalMode.DEFAULT,
|
||||
approvalMode: this.getAllowApprovalMode(),
|
||||
};
|
||||
if (payload.approved) {
|
||||
const newMode = payload.approvalMode ?? ApprovalMode.DEFAULT;
|
||||
|
||||
if (newMode === ApprovalMode.PLAN || newMode === ApprovalMode.YOLO) {
|
||||
if (newMode === ApprovalMode.PLAN) {
|
||||
throw new Error(`Unexpected approval mode: ${newMode}`);
|
||||
}
|
||||
|
||||
@@ -254,4 +252,18 @@ Ask the user for specific feedback on how to improve the plan.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the approval mode to switch to when plan mode is exited via a policy ALLOW.
|
||||
* In non-interactive environments, this defaults to YOLO to allow automated execution.
|
||||
*/
|
||||
private getAllowApprovalMode(): ApprovalMode {
|
||||
if (!this.config.isInteractive()) {
|
||||
// For non-interactive environment requires minimal user action, exit as YOLO mode for plan implementation.
|
||||
return ApprovalMode.YOLO;
|
||||
}
|
||||
// By default, YOLO mode in interactive environment cannot enter/exit plan mode.
|
||||
// Always exit plan mode and move to default approval mode if exit_plan_mode tool is configured with allow decision.
|
||||
return ApprovalMode.DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { coreEvents } from './events.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
|
||||
// Mock fs/promises before importing the module under test
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
access: vi.fn(),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock Storage to return a predictable directory
|
||||
vi.mock('../config/storage.js', () => ({
|
||||
Storage: {
|
||||
getGlobalGeminiDir: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { getBrowserConsentIfNeeded } from './browserConsent.js';
|
||||
|
||||
describe('browserConsent', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(Storage.getGlobalGeminiDir).mockReturnValue('/mock/.gemini');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return true if consent file already exists', async () => {
|
||||
// Consent file exists — fs.access resolves
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
|
||||
const result = await getBrowserConsentIfNeeded();
|
||||
|
||||
expect(result).toBe(true);
|
||||
// Should not emit a consent request
|
||||
const emitSpy = vi.spyOn(coreEvents, 'emitConsentRequest');
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should auto-accept in non-interactive mode (no listeners)', async () => {
|
||||
// Consent file does not exist
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
// No listeners registered
|
||||
vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(0);
|
||||
|
||||
const result = await getBrowserConsentIfNeeded();
|
||||
|
||||
expect(result).toBe(true);
|
||||
// Should persist the consent
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('browser-consent-acknowledged.txt'),
|
||||
expect.stringContaining('consent acknowledged'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should request consent interactively and return true when accepted', async () => {
|
||||
// Consent file does not exist
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
// Simulate interactive mode: there is at least one listener
|
||||
vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1);
|
||||
// Mock emitConsentRequest to auto-confirm
|
||||
vi.spyOn(coreEvents, 'emitConsentRequest').mockImplementation((payload) => {
|
||||
payload.onConfirm(true);
|
||||
});
|
||||
|
||||
const result = await getBrowserConsentIfNeeded();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(coreEvents.emitConsentRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining('Privacy Notice'),
|
||||
}),
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when user declines consent', async () => {
|
||||
// Consent file does not exist
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
// Simulate interactive mode
|
||||
vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1);
|
||||
// Mock emitConsentRequest to decline
|
||||
vi.spyOn(coreEvents, 'emitConsentRequest').mockImplementation((payload) => {
|
||||
payload.onConfirm(false);
|
||||
});
|
||||
|
||||
const result = await getBrowserConsentIfNeeded();
|
||||
|
||||
expect(result).toBe(false);
|
||||
// Should NOT persist consent
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include privacy policy link in the prompt', async () => {
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1);
|
||||
vi.spyOn(coreEvents, 'emitConsentRequest').mockImplementation((payload) => {
|
||||
payload.onConfirm(true);
|
||||
});
|
||||
|
||||
await getBrowserConsentIfNeeded();
|
||||
|
||||
expect(coreEvents.emitConsentRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining('policies.google.com/privacy'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CoreEvent, coreEvents } from './events.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
/** Sentinel file written after the user acknowledges the browser privacy notice. */
|
||||
const BROWSER_CONSENT_FLAG_FILE = 'browser-consent-acknowledged.txt';
|
||||
|
||||
/** Default browser profile directory name within ~/.gemini/ */
|
||||
const BROWSER_PROFILE_DIR = 'cli-browser-profile';
|
||||
|
||||
/**
|
||||
* Ensures the user has acknowledged the browser agent privacy notice.
|
||||
*
|
||||
* On first invocation (per profile), an interactive consent dialog is shown
|
||||
* describing chrome-devtools-mcp's data collection and the fact that browser
|
||||
* content is exposed to the AI model. A sentinel file is written to the
|
||||
* browser profile directory once the user accepts.
|
||||
*
|
||||
* @returns `true` if consent was already given or the user accepted,
|
||||
* `false` if the user declined.
|
||||
*/
|
||||
export async function getBrowserConsentIfNeeded(): Promise<boolean> {
|
||||
const consentFilePath = path.join(
|
||||
Storage.getGlobalGeminiDir(),
|
||||
BROWSER_PROFILE_DIR,
|
||||
BROWSER_CONSENT_FLAG_FILE,
|
||||
);
|
||||
|
||||
// Fast path: consent already persisted.
|
||||
try {
|
||||
await fs.access(consentFilePath);
|
||||
return true;
|
||||
} catch {
|
||||
// File doesn't exist — need to request consent.
|
||||
void 0;
|
||||
}
|
||||
|
||||
// Non-interactive mode (no UI listeners): auto-accept.
|
||||
if (coreEvents.listenerCount(CoreEvent.ConsentRequest) === 0) {
|
||||
await markConsentAsAcknowledged(consentFilePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
const prompt =
|
||||
'🔒 Browser Agent Privacy Notice\n\n' +
|
||||
'The Browser Agent uses chrome-devtools-mcp to control your browser. ' +
|
||||
'Please note:\n\n' +
|
||||
'• Chrome DevTools MCP collects usage statistics by default ' +
|
||||
'(can be disabled via privacy settings)\n' +
|
||||
'• Performance tools may send trace URLs to Google CrUX API\n' +
|
||||
'• Browser content will be exposed to the AI model for analysis\n' +
|
||||
'• All data is handled per the Google Privacy Policy ' +
|
||||
'(https://policies.google.com/privacy)\n\n' +
|
||||
'Do you understand and consent to proceed?';
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
coreEvents.emitConsentRequest({
|
||||
prompt,
|
||||
onConfirm: async (confirmed: boolean) => {
|
||||
if (confirmed) {
|
||||
await markConsentAsAcknowledged(consentFilePath);
|
||||
}
|
||||
resolve(confirmed);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a sentinel file so consent is not requested again.
|
||||
*/
|
||||
async function markConsentAsAcknowledged(
|
||||
consentFilePath: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(consentFilePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
consentFilePath,
|
||||
`Browser privacy consent acknowledged at ${new Date().toISOString()}\n`,
|
||||
);
|
||||
} catch {
|
||||
// Best-effort: if we can't persist, the dialog will appear again next time.
|
||||
void 0;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,18 @@ export async function getDirectoryContextString(
|
||||
const workspaceContext = config.getWorkspaceContext();
|
||||
const workspaceDirectories = workspaceContext.getDirectories();
|
||||
|
||||
|
||||
try {
|
||||
const { execSync } = await import('child_process');
|
||||
// We try to run tilth using npx (which installs if missing)
|
||||
const tilthOutput = execSync('npx -y tilth --map --scope ' + workspaceDirectories[0]).toString();
|
||||
const dirList = workspaceDirectories.map((dir) => ` - ${dir}`).join('\n');
|
||||
return `Use the following map to skip the need to read files and folders:
|
||||
- **Workspace Directories:**\n${dirList}\n- **Directory Structure:**\n\n${tilthOutput}`;
|
||||
} catch (e) {
|
||||
// fallback if tilth fails
|
||||
}
|
||||
|
||||
const folderStructures = await Promise.all(
|
||||
workspaceDirectories.map((dir) =>
|
||||
getFolderStructure(dir, {
|
||||
@@ -76,6 +88,17 @@ My operating system is: ${platform}
|
||||
The project's temporary directory is: ${tempDir}
|
||||
${directoryContext}
|
||||
|
||||
|
||||
You have access to a tool called \`tilth\` via \`run_shell_command\`.
|
||||
It is a structural code reader designed for AI agents.
|
||||
It understands code semantics (functions, classes, imports, etc.).
|
||||
|
||||
Useful \`tilth\` commands:
|
||||
1. \`tilth <file_path>\` - Reads a file. If large, returns a structural outline (functions/classes) instead of truncating.
|
||||
2. \`tilth <symbol> --scope .\` - Finds definition and usages of a specific function or class across the codebase.
|
||||
3. \`tilth <file_path> --section 45-89\` - Reads a specific line range or markdown heading.
|
||||
|
||||
|
||||
${environmentMemory}
|
||||
</session_context>`.trim();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user