mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-01 20:51:00 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae6d39d9f8 |
+460
-353
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.35.0-preview.1
|
||||
# Preview release: v0.34.0-preview.4
|
||||
|
||||
Released: March 17, 2026
|
||||
Released: March 16, 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,364 +13,471 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## Highlights
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- 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
|
||||
- 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
|
||||
@gemini-cli-robot in
|
||||
[#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
|
||||
[#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
|
||||
[#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
|
||||
[#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
|
||||
[#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
|
||||
[#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
|
||||
[#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
|
||||
@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
|
||||
@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
|
||||
[#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
|
||||
[#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
|
||||
[#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
|
||||
[#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)
|
||||
[#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
|
||||
@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)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.34.0-preview.4...v0.35.0-preview.1
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.33.0-preview.15...v0.34.0-preview.4
|
||||
|
||||
@@ -375,8 +375,6 @@ 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,19 +58,6 @@ 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
|
||||
|
||||
@@ -894,9 +894,6 @@ describe('Session', () => {
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
status: 'completed',
|
||||
title: 'Test Tool',
|
||||
locations: [],
|
||||
kind: 'read',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1309,18 +1306,6 @@ 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(
|
||||
@@ -1336,65 +1321,6 @@ 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({
|
||||
@@ -1508,7 +1434,6 @@ describe('Session', () => {
|
||||
content: expect.objectContaining({ text: 'Tool failed' }),
|
||||
}),
|
||||
]),
|
||||
kind: 'read',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -966,10 +966,7 @@ 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;
|
||||
@@ -1033,7 +1030,6 @@ export class Session {
|
||||
content: [
|
||||
{ type: 'content', content: { type: 'text', text: error.message } },
|
||||
],
|
||||
kind: toAcpToolKind(tool.kind),
|
||||
});
|
||||
|
||||
this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [
|
||||
@@ -1328,10 +1324,7 @@ 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$/;
|
||||
@@ -1375,7 +1368,6 @@ export class Session {
|
||||
},
|
||||
},
|
||||
],
|
||||
kind: toAcpToolKind(readManyFilesTool.kind),
|
||||
});
|
||||
|
||||
throw error;
|
||||
|
||||
@@ -35,11 +35,7 @@ export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
|
||||
? Math.min(availableHeightLimit, maxLines)
|
||||
: (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT);
|
||||
|
||||
const lastLines = disableTruncation
|
||||
? data
|
||||
: numLinesRetained === 0
|
||||
? []
|
||||
: data.slice(-numLinesRetained);
|
||||
const lastLines = disableTruncation ? data : data.slice(-numLinesRetained);
|
||||
return (
|
||||
<Box flexDirection="column" width={width} flexShrink={0} overflow="hidden">
|
||||
{lastLines.map((line: AnsiLine, lineIndex: number) => (
|
||||
|
||||
@@ -48,7 +48,6 @@ export const MainContent = () => {
|
||||
pendingHistoryItems,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
availableTerminalHeight,
|
||||
cleanUiDetailsVisible,
|
||||
} = uiState;
|
||||
const showHeaderDetails = cleanUiDetailsVisible;
|
||||
@@ -142,7 +141,7 @@ export const MainContent = () => {
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight ? availableTerminalHeight : undefined
|
||||
uiState.constrainHeight ? staticAreaMaxItemHeight : undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
@@ -161,7 +160,7 @@ export const MainContent = () => {
|
||||
[
|
||||
pendingHistoryItems,
|
||||
uiState.constrainHeight,
|
||||
availableTerminalHeight,
|
||||
staticAreaMaxItemHeight,
|
||||
mainAreaWidth,
|
||||
showConfirmationQueue,
|
||||
confirmingTool,
|
||||
|
||||
@@ -6,12 +6,11 @@ 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 █ │
|
||||
@@ -28,12 +27,11 @@ 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 █ │
|
||||
@@ -49,9 +47,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ ... first 9 lines hidden (Ctrl+O to show) ... │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ ... first 11 lines hidden (Ctrl+O to show) ... │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
|
||||
@@ -199,7 +199,7 @@ describe('<ShellToolMessage />', () => {
|
||||
[
|
||||
'uses full availableTerminalHeight when focused in alternate buffer mode',
|
||||
100,
|
||||
98,
|
||||
98, // 100 - 2
|
||||
true,
|
||||
false,
|
||||
],
|
||||
|
||||
@@ -4,18 +4,16 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||
import {
|
||||
type SerializableConfirmationDetails,
|
||||
type ToolCallConfirmationDetails,
|
||||
type Config,
|
||||
ToolConfirmationOutcome,
|
||||
import type {
|
||||
SerializableConfirmationDetails,
|
||||
ToolCallConfirmationDetails,
|
||||
Config,
|
||||
} 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 =
|
||||
@@ -648,63 +646,4 @@ 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 { useEffect, useMemo, useCallback, useState } from 'react';
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
|
||||
@@ -79,7 +79,6 @@ export const ToolConfirmationMessage: React.FC<
|
||||
callId,
|
||||
expanded: false,
|
||||
});
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const isMcpToolDetailsExpanded =
|
||||
mcpDetailsExpansionState.callId === callId
|
||||
? mcpDetailsExpansionState.expanded
|
||||
@@ -184,7 +183,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
setIsCancelling(true);
|
||||
handleConfirm(ToolConfirmationOutcome.Cancel);
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
@@ -197,20 +196,6 @@ 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 !== undefined) {
|
||||
if (maxLines) {
|
||||
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 !== undefined) {
|
||||
if (Array.isArray(data) && !isAlternateBuffer && maxLines) {
|
||||
if (data.length > maxLines) {
|
||||
// We will have a label from MaxSizedBox. Reserve space for it.
|
||||
const targetLines = Math.max(1, maxLines - 1);
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
/**
|
||||
* @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,13 +46,12 @@ export function calculateToolContentMaxLines(options: {
|
||||
? TOOL_RESULT_ASB_RESERVED_LINE_COUNT
|
||||
: TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT;
|
||||
|
||||
let contentHeight =
|
||||
availableTerminalHeight !== undefined
|
||||
? Math.max(
|
||||
availableTerminalHeight - TOOL_RESULT_STATIC_HEIGHT - reservedLines,
|
||||
TOOL_RESULT_MIN_LINES_SHOWN + 1,
|
||||
)
|
||||
: undefined;
|
||||
let contentHeight = availableTerminalHeight
|
||||
? Math.max(
|
||||
availableTerminalHeight - TOOL_RESULT_STATIC_HEIGHT - reservedLines,
|
||||
TOOL_RESULT_MIN_LINES_SHOWN + 1,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (maxLinesLimit !== undefined) {
|
||||
contentHeight =
|
||||
@@ -101,10 +100,7 @@ export function calculateShellMaxLines(options: {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const maxLinesBasedOnHeight = Math.max(
|
||||
1,
|
||||
availableTerminalHeight - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
|
||||
);
|
||||
const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2);
|
||||
|
||||
// 3. Handle ASB mode focus expansion.
|
||||
// We allow a focused shell in ASB mode to take up the full available height,
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { promptComponent, promptTemplate, renderSnippet, renderTemplate, enabledWhen, section, xmlSection, each, switchOn, type Snippet, type PromptTemplateBase } from './promptTemplating.js';
|
||||
|
||||
// Sample prompt components.
|
||||
type MainPromptOptions = { isInteractive: boolean };
|
||||
|
||||
const identity = promptComponent('Your name is Gemini CLI and you are a coding agent');
|
||||
|
||||
const interactive = promptComponent<MainPromptOptions>(
|
||||
enabledWhen('isInteractive', 'You are operating in an interactive session')
|
||||
);
|
||||
|
||||
const mainPrompt = promptComponent<MainPromptOptions>(identity, interactive);
|
||||
|
||||
const variadicPrompt = promptComponent('A', 'B', 'C');
|
||||
|
||||
const nestedVariadicPrompt = promptComponent('A', ['B', 'C'], 'D');
|
||||
|
||||
const enabledWhenPrompt = promptComponent<Record<string, boolean>>(
|
||||
'Always',
|
||||
enabledWhen('show', 'Sometimes')
|
||||
);
|
||||
|
||||
const xmlPrompt = xmlSection('rules', 'Be helpful');
|
||||
|
||||
const emptyXmlPrompt = xmlSection('rules', '');
|
||||
|
||||
const markdownPrompt = section('Rules', 'Be helpful');
|
||||
|
||||
const emptyMarkdownPrompt = section('Rules', '');
|
||||
|
||||
const customHeaderMarkdownPrompt = section('Rules', 'Be helpful', { headerLevel: 3 });
|
||||
|
||||
const eachPrompt = each<{ items: string[] }, string>('items', (item: string) => `Item: ${item}`);
|
||||
|
||||
const eachCustomSeparatorPrompt = each<{ items: string[] }, string>('items', (item: string) => item, ', ');
|
||||
|
||||
const switchOnPrompt = switchOn<{ mode: string | number }>('mode', {
|
||||
fast: 'Speed is key',
|
||||
safe: 'Safety first',
|
||||
1: 'Mode one',
|
||||
});
|
||||
|
||||
// A high-level template.
|
||||
interface SystemPromptOptions { name: string }
|
||||
interface SystemPromptTemplate extends PromptTemplateBase<SystemPromptOptions> {
|
||||
identity: Snippet<SystemPromptOptions>;
|
||||
instructions: Snippet<SystemPromptOptions>;
|
||||
}
|
||||
|
||||
const mainTemplate = promptTemplate<SystemPromptOptions, SystemPromptTemplate>({
|
||||
identity: (opt: SystemPromptOptions) => `Your name is ${opt.name}`,
|
||||
instructions: 'Be helpful',
|
||||
});
|
||||
|
||||
describe('promptTemplating', () => {
|
||||
it('should take variadic arguments', () => {
|
||||
expect(renderSnippet({}, variadicPrompt)).toBe('A,B,C');
|
||||
});
|
||||
|
||||
it('should handle nested arrays (variadic with arrays)', () => {
|
||||
expect(renderSnippet({}, nestedVariadicPrompt)).toBe('A,B,C,D');
|
||||
});
|
||||
|
||||
it('should handle enabledWhen', () => {
|
||||
expect(renderSnippet({ show: true }, enabledWhenPrompt)).toBe('Always,Sometimes');
|
||||
expect(renderSnippet({ show: false }, enabledWhenPrompt)).toBe('Always,');
|
||||
});
|
||||
|
||||
it('should handle xmlSection', () => {
|
||||
expect(renderSnippet({}, xmlPrompt)).toBe('<rules>\nBe helpful\n</rules>');
|
||||
});
|
||||
|
||||
it('should return empty string for xmlSection if content is empty', () => {
|
||||
expect(renderSnippet({}, emptyXmlPrompt)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle markdown section with default header', () => {
|
||||
expect(renderSnippet({}, markdownPrompt)).toBe('# Rules\n\nBe helpful');
|
||||
});
|
||||
|
||||
it('should return empty string for markdown section if content is empty', () => {
|
||||
expect(renderSnippet({}, emptyMarkdownPrompt)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle markdown section with custom header level', () => {
|
||||
expect(renderSnippet({}, customHeaderMarkdownPrompt)).toBe('### Rules\n\nBe helpful');
|
||||
});
|
||||
|
||||
it('should handle each', () => {
|
||||
const options = { items: ['A', 'B'] };
|
||||
expect(renderSnippet(options, eachPrompt)).toBe('Item: A\nItem: B');
|
||||
});
|
||||
|
||||
it('should handle switchOn', () => {
|
||||
expect(renderSnippet({ mode: 'fast' }, switchOnPrompt)).toBe('Speed is key');
|
||||
expect(renderSnippet({ mode: 'safe' }, switchOnPrompt)).toBe('Safety first');
|
||||
expect(renderSnippet({ mode: 'unknown' }, switchOnPrompt)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle switchOn with numeric values', () => {
|
||||
expect(renderSnippet({ mode: 1 }, switchOnPrompt)).toBe('Mode one');
|
||||
});
|
||||
|
||||
it('should handle each with custom separator', () => {
|
||||
const options = { items: ['A', 'B'] };
|
||||
expect(renderSnippet(options, eachCustomSeparatorPrompt)).toBe('A, B');
|
||||
});
|
||||
|
||||
it('should handle each with non-array value', () => {
|
||||
const options = { items: 'not an array' as unknown as string[] };
|
||||
expect(renderSnippet(options, eachPrompt)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle section with different header levels', () => {
|
||||
const h2Section = section('Level 2', 'Content', { headerLevel: 2 });
|
||||
const h6Section = section('Level 6', 'Content', { headerLevel: 6 });
|
||||
expect(renderSnippet({}, h2Section)).toBe('## Level 2\n\nContent');
|
||||
expect(renderSnippet({}, h6Section)).toBe('###### Level 6\n\nContent');
|
||||
});
|
||||
|
||||
it('should handle toPrompt with basic types', () => {
|
||||
expect(renderSnippet({}, 'just a string')).toBe('just a string');
|
||||
expect(renderSnippet({ name: 'test' }, (opt: { name: string }) => opt.name)).toBe('test');
|
||||
});
|
||||
|
||||
it('should handle toPrompt with empty array or object', () => {
|
||||
expect(renderSnippet({}, [])).toBe('');
|
||||
expect(renderSnippet({}, {})).toBe('');
|
||||
});
|
||||
|
||||
it('should handle renderTemplate with a record of snippets (implements required members)', () => {
|
||||
const options = {
|
||||
name: 'Gemini CLI',
|
||||
};
|
||||
expect(renderTemplate(options, mainTemplate)).toBe('Your name is Gemini CLI,Be helpful');
|
||||
});
|
||||
|
||||
it('should output correctly for mainPrompt', () => {
|
||||
const text = renderSnippet({ isInteractive: true }, mainPrompt);
|
||||
expect(text).toContain('Your name is Gemini CLI and you are a coding agent');
|
||||
expect(text).toContain('You are operating in an interactive session');
|
||||
});
|
||||
});
|
||||
@@ -1,262 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview
|
||||
* This module provides a functional, type-safe DSL for constructing complex LLM prompts.
|
||||
* It supports dynamic content generation, conditional snippets, structural formatting (XML, Markdown),
|
||||
* and collection iteration, ensuring that prompt logic remains modular and maintainable.
|
||||
*
|
||||
* Key Features:
|
||||
* - **Type Safety**: Prompts are tied to an options interface, ensuring all dynamic data is validated.
|
||||
* - **Composition**: Small, reusable snippets can be combined. Templates (`promptTemplate`) ensure that like components have the same required elements, while components (`promptComponent`) are used for constructing the prompt.
|
||||
* - **Conditional Logic**: Use `enabledWhen` and `switchOn` to prune or swap prompt sections dynamically.
|
||||
* - **Structural Helpers**: `xmlSection` and `section` (Markdown) handle boilerplate formatting and empty-state pruning.
|
||||
* - **Iteration**: `each` simplifies rendering lists of tools, rules, or historical context.
|
||||
*
|
||||
* @example
|
||||
* interface SystemOptions {
|
||||
* name: string;
|
||||
* isInteractive: boolean;
|
||||
* rules: string[];
|
||||
* mode: 'fast' | 'creative';
|
||||
* }
|
||||
*
|
||||
* interface SystemTemplate extends PromptTemplateBase<SystemOptions> {
|
||||
* identity: Snippet<SystemOptions>;
|
||||
* constraints: Snippet<SystemOptions>;
|
||||
* capabilities: Snippet<SystemOptions>;
|
||||
* }
|
||||
*
|
||||
* const systemPrompt = promptTemplate<SystemOptions, SystemTemplate>({
|
||||
* identity: (opt) => `Your name is ${opt.name}.`,
|
||||
* constraints: xmlSection("rules", each<SystemOptions, string>("rules", (r) => `- ${r}`)),
|
||||
* capabilities: promptComponent(
|
||||
* switchOn<SystemOptions>("mode", {
|
||||
* fast: "Optimize for speed and brevity.",
|
||||
* creative: "Optimize for detail and exploration."
|
||||
* }),
|
||||
* enabledWhen<SystemOptions>("isInteractive", "You are in a live session; be brief.")
|
||||
* )
|
||||
* });
|
||||
*
|
||||
* const output = renderTemplate({
|
||||
* name: "Gemini CLI",
|
||||
* isInteractive: true,
|
||||
* rules: ["No spoilers", "Be helpful"],
|
||||
* mode: "fast"
|
||||
* }, systemPrompt);
|
||||
*/
|
||||
|
||||
/**
|
||||
* A function that generates a string snippet based on the provided options.
|
||||
*
|
||||
* @example
|
||||
* interface Options { name: string; }
|
||||
* const snippet: DynamicSnippet<Options> = (options) => `Hello, ${options.name}!`;
|
||||
*/
|
||||
export type DynamicSnippet<TOption> = (options: TOption) => string;
|
||||
|
||||
/**
|
||||
* A snippet can be a string, a dynamic snippet function, an array of snippets,
|
||||
* or a full prompt template base object.
|
||||
*
|
||||
* @example
|
||||
* interface Options { name: string; }
|
||||
* const simple: Snippet<Options> = "Hello World";
|
||||
* const dynamic: Snippet<Options> = (opt) => opt.name;
|
||||
* const combined: Snippet<Options> = ["Hello ", (opt) => opt.name];
|
||||
*/
|
||||
export type Snippet<TOption> =
|
||||
| string
|
||||
| DynamicSnippet<TOption>
|
||||
| Array<Snippet<TOption>>
|
||||
| PromptTemplateBase<TOption>;
|
||||
|
||||
/**
|
||||
* The base interface for a prompt template, where each key is a snippet.
|
||||
*/
|
||||
export interface PromptTemplateBase<TOption> {
|
||||
[key: string]: Snippet<TOption>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a complete prompt template defined by TTemplate.
|
||||
*/
|
||||
export type PromptTemplate<
|
||||
TOption,
|
||||
TTemplate extends PromptTemplateBase<TOption>,
|
||||
> = TTemplate;
|
||||
|
||||
/**
|
||||
* Defines a prompt template.
|
||||
*
|
||||
* @example
|
||||
* interface Options { name: string; }
|
||||
* interface Template extends PromptTemplateBase<Options> { greeting: Snippet<Options>; }
|
||||
* const myTemplate = promptTemplate<Options, Template>({
|
||||
* greeting: (opt) => `Hello ${opt.name}`
|
||||
* });
|
||||
*/
|
||||
export function promptTemplate<
|
||||
TOption,
|
||||
TTemplate extends PromptTemplateBase<TOption>,
|
||||
>(template: TTemplate): PromptTemplate<TOption, TTemplate> {
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines multiple snippets into a single component.
|
||||
*
|
||||
* @example
|
||||
* interface Options { second: string; }
|
||||
* const component = promptComponent<Options>("First part. ", (opt) => opt.second);
|
||||
*/
|
||||
export function promptComponent<TOption>(
|
||||
...snippets: Array<Snippet<TOption>>
|
||||
): Snippet<TOption> {
|
||||
return snippets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditionally includes a snippet if a specific option is truthy.
|
||||
*
|
||||
* @example
|
||||
* interface Options { verbose: boolean; }
|
||||
* const conditional = enabledWhen<Options>("verbose", "Running in verbose mode.");
|
||||
*/
|
||||
export function enabledWhen<TOption>(
|
||||
optionName: keyof TOption,
|
||||
snippet: Snippet<TOption>,
|
||||
): Snippet<TOption> {
|
||||
return (option: TOption) =>
|
||||
option[optionName] ? renderSnippet(option, snippet) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a snippet in XML tags.
|
||||
*
|
||||
* @example
|
||||
* interface Options { content: string; }
|
||||
* const xml = xmlSection<Options>("details", (opt) => opt.content);
|
||||
* // Output: <details>\nSome content\n</details>
|
||||
*/
|
||||
export function xmlSection<TOption>(
|
||||
name: string,
|
||||
snippet: Snippet<TOption>,
|
||||
): Snippet<TOption> {
|
||||
return (options: TOption) => {
|
||||
const content = renderSnippet(options, snippet);
|
||||
return content ? `<${name}>\n${content}\n</${name}>` : '';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for a markdown section.
|
||||
*/
|
||||
export interface SectionOptions {
|
||||
/** The level of the markdown header (default is 1). */
|
||||
headerLevel?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a markdown-style section with a header.
|
||||
*
|
||||
* @example
|
||||
* interface Options { title: string; }
|
||||
* const mdSection = section<Options>("Introduction", "This is the content.", { headerLevel: 2 });
|
||||
* // Output: ## Introduction\n\nThis is the content.
|
||||
*/
|
||||
export function section<TOption>(
|
||||
name: string,
|
||||
snippet: Snippet<TOption>,
|
||||
sectionOptions?: SectionOptions,
|
||||
): Snippet<TOption> {
|
||||
return (options: TOption) => {
|
||||
const content = renderSnippet(options, snippet);
|
||||
const level = sectionOptions?.headerLevel ?? 1;
|
||||
const hashes = '#'.repeat(level);
|
||||
return content ? `${hashes} ${name}\n\n${content}` : '';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates over an array in the options and renders a snippet for each item.
|
||||
*
|
||||
* @example
|
||||
* interface Options { items: string[]; }
|
||||
* const list = each<Options, string>("items", (item) => `- ${item}`);
|
||||
*/
|
||||
export function each<TOption, TItem = unknown>(
|
||||
optionName: keyof TOption,
|
||||
snippet: Snippet<TItem>,
|
||||
separator = '\n',
|
||||
): Snippet<TOption> {
|
||||
return (options: TOption) => {
|
||||
const items = options[optionName];
|
||||
if (Array.isArray(items)) {
|
||||
return items
|
||||
.map((item: TItem) => renderSnippet(item, snippet))
|
||||
.join(separator);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a snippet based on the value of a specific option.
|
||||
*
|
||||
* @example
|
||||
* interface Options { mode: 'a' | 'b'; }
|
||||
* const router = switchOn<Options>("mode", {
|
||||
* a: "Mode A selected",
|
||||
* b: "Mode B selected"
|
||||
* });
|
||||
*/
|
||||
export function switchOn<TOption>(
|
||||
optionName: keyof TOption,
|
||||
cases: Record<string, Snippet<TOption>>,
|
||||
): Snippet<TOption> {
|
||||
return (options: TOption) => {
|
||||
const value = String(options[optionName]);
|
||||
const caseSnippet = cases[value];
|
||||
return caseSnippet ? renderSnippet(options, caseSnippet) : '';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an entire template into a single string.
|
||||
*/
|
||||
export function renderTemplate<
|
||||
TOption,
|
||||
TTemplate extends PromptTemplateBase<TOption>,
|
||||
>(options: TOption, implementation: TTemplate): string {
|
||||
return Object.values(implementation)
|
||||
.map((eachSnippet) => renderSnippet<TOption>(options, eachSnippet))
|
||||
.join();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a snippet into a string.
|
||||
*/
|
||||
export function renderSnippet<TOption>(
|
||||
options: TOption,
|
||||
snippet: Snippet<TOption>,
|
||||
): string {
|
||||
if (typeof snippet === 'string') {
|
||||
return snippet;
|
||||
} else if (Array.isArray(snippet)) {
|
||||
return snippet
|
||||
.map((eachSnippet) => renderSnippet<TOption>(options, eachSnippet))
|
||||
.join();
|
||||
} else if (typeof snippet === 'function') {
|
||||
return snippet(options);
|
||||
} else {
|
||||
return Object.values(snippet)
|
||||
.map((eachSnippet) => renderSnippet<TOption>(options, eachSnippet))
|
||||
.join();
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
/**
|
||||
* @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');
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* @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();
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* @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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* @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")
|
||||
)
|
||||
`;
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* @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();
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* @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,7 +12,6 @@ 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();
|
||||
@@ -125,20 +124,23 @@ describe('createSandboxManager', () => {
|
||||
expect(manager).toBeInstanceOf(NoopSandboxManager);
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
},
|
||||
);
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ 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.
|
||||
@@ -99,9 +98,6 @@ 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();
|
||||
|
||||
@@ -425,4 +425,159 @@ describe('CoreEventEmitter', () => {
|
||||
expect(listener).toHaveBeenCalledExactlyOnceWith(payload);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memory-bounded Backlog', () => {
|
||||
it('should strip older events when MAX_BACKLOG_PAYLOAD_BYTES is exceeded', () => {
|
||||
// Set small budget: 64KB
|
||||
|
||||
(
|
||||
events as unknown as {
|
||||
_setBudgetsForTesting: (
|
||||
maxBacklog: number,
|
||||
maxSingle: number,
|
||||
) => void;
|
||||
_currentBacklogPayloadBytes: number;
|
||||
}
|
||||
)._setBudgetsForTesting(64 * 1024, 1024 * 1024);
|
||||
|
||||
const payloadSize = 20 * 1024; // 20KB each
|
||||
const chunk = 'a'.repeat(payloadSize);
|
||||
|
||||
// Emit 5 events (total 100KB > 64KB)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
events.emitOutput(false, chunk);
|
||||
}
|
||||
|
||||
const listener = vi.fn();
|
||||
events.on(CoreEvent.Output, listener);
|
||||
events.drainBacklogs();
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(5);
|
||||
|
||||
// The first 2 events should be stripped to stay within the 64KB budget
|
||||
expect(listener.mock.calls[0][0]).toMatchObject({
|
||||
chunk: '',
|
||||
omitted: true,
|
||||
originalByteLength: payloadSize,
|
||||
});
|
||||
expect(listener.mock.calls[1][0]).toMatchObject({
|
||||
chunk: '',
|
||||
omitted: true,
|
||||
originalByteLength: payloadSize,
|
||||
});
|
||||
|
||||
// The last 3 events should be intact (3 * 20KB = 60KB <= 64KB)
|
||||
expect(listener.mock.calls[2][0]).toMatchObject({ chunk });
|
||||
expect(listener.mock.calls[3][0]).toMatchObject({ chunk });
|
||||
expect(listener.mock.calls[4][0]).toMatchObject({ chunk });
|
||||
|
||||
// Verify internal byte counter is reset
|
||||
|
||||
expect(
|
||||
(
|
||||
events as unknown as {
|
||||
_setBudgetsForTesting: (
|
||||
maxBacklog: number,
|
||||
maxSingle: number,
|
||||
) => void;
|
||||
_currentBacklogPayloadBytes: number;
|
||||
}
|
||||
)._currentBacklogPayloadBytes,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('should truncate a single huge event that exceeds MAX_SINGLE_EVENT_PAYLOAD_BYTES', () => {
|
||||
(
|
||||
events as unknown as {
|
||||
_setBudgetsForTesting: (
|
||||
maxBacklog: number,
|
||||
maxSingle: number,
|
||||
) => void;
|
||||
_currentBacklogPayloadBytes: number;
|
||||
}
|
||||
)._setBudgetsForTesting(10 * 1024 * 1024, 1 * 1024 * 1024); // 1MB single event budget
|
||||
|
||||
const payloadSize = 5 * 1024 * 1024; // 5MB
|
||||
const chunk = 'b'.repeat(payloadSize);
|
||||
|
||||
events.emitOutput(false, chunk);
|
||||
|
||||
const listener = vi.fn();
|
||||
events.on(CoreEvent.Output, listener);
|
||||
events.drainBacklogs();
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
const output = listener.mock.calls[0][0];
|
||||
|
||||
// Should be truncated to 1MB
|
||||
expect(output.chunk.length).toBe(1 * 1024 * 1024);
|
||||
expect(output.truncated).toBe(true);
|
||||
expect(output.originalByteLength).toBe(payloadSize);
|
||||
|
||||
expect(
|
||||
(
|
||||
events as unknown as {
|
||||
_setBudgetsForTesting: (
|
||||
maxBacklog: number,
|
||||
maxSingle: number,
|
||||
) => void;
|
||||
_currentBacklogPayloadBytes: number;
|
||||
}
|
||||
)._currentBacklogPayloadBytes,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('should preserve MAX_BACKLOG_SIZE events while staying within memory budget', () => {
|
||||
(
|
||||
events as unknown as {
|
||||
_setBudgetsForTesting: (
|
||||
maxBacklog: number,
|
||||
maxSingle: number,
|
||||
) => void;
|
||||
_currentBacklogPayloadBytes: number;
|
||||
}
|
||||
)._setBudgetsForTesting(64 * 1024, 1024 * 1024);
|
||||
|
||||
const MAX_BACKLOG_SIZE = 10000;
|
||||
const smallChunk = 'c'.repeat(10); // 10 bytes
|
||||
|
||||
// Emit enough to fill the backlog entirely
|
||||
for (let i = 0; i < MAX_BACKLOG_SIZE; i++) {
|
||||
events.emitOutput(false, smallChunk);
|
||||
}
|
||||
|
||||
// Total bytes = 100,000 > 65,536 (64KB budget)
|
||||
// Older items will be stripped to respect the memory budget.
|
||||
|
||||
const listener = vi.fn();
|
||||
events.on(CoreEvent.Output, listener);
|
||||
events.drainBacklogs();
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(MAX_BACKLOG_SIZE);
|
||||
|
||||
// Verify the first item was stripped
|
||||
expect(listener.mock.calls[0][0]).toMatchObject({
|
||||
chunk: '',
|
||||
omitted: true,
|
||||
originalByteLength: 10,
|
||||
});
|
||||
|
||||
// Verify the latest items are preserved
|
||||
expect(listener.mock.calls[MAX_BACKLOG_SIZE - 1][0]).toMatchObject({
|
||||
chunk: smallChunk,
|
||||
});
|
||||
|
||||
expect(
|
||||
(
|
||||
events as unknown as {
|
||||
_setBudgetsForTesting: (
|
||||
maxBacklog: number,
|
||||
maxSingle: number,
|
||||
) => void;
|
||||
_currentBacklogPayloadBytes: number;
|
||||
}
|
||||
)._currentBacklogPayloadBytes,
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,9 @@ export type FeedbackSeverity = 'info' | 'warning' | 'error';
|
||||
* Payload for the 'user-feedback' event.
|
||||
*/
|
||||
export interface UserFeedbackPayload {
|
||||
truncated?: boolean;
|
||||
originalByteLength?: number;
|
||||
omitted?: boolean;
|
||||
/**
|
||||
* The severity level determines how the message is rendered in the UI
|
||||
* (e.g. colored text, specific icon).
|
||||
@@ -56,6 +59,9 @@ export interface ModelChangedPayload {
|
||||
* Payload for the 'console-log' event.
|
||||
*/
|
||||
export interface ConsoleLogPayload {
|
||||
truncated?: boolean;
|
||||
originalByteLength?: number;
|
||||
omitted?: boolean;
|
||||
type: 'log' | 'warn' | 'error' | 'debug' | 'info';
|
||||
content: string;
|
||||
}
|
||||
@@ -64,6 +70,9 @@ export interface ConsoleLogPayload {
|
||||
* Payload for the 'output' event.
|
||||
*/
|
||||
export interface OutputPayload {
|
||||
truncated?: boolean;
|
||||
originalByteLength?: number;
|
||||
omitted?: boolean;
|
||||
isStderr: boolean;
|
||||
chunk: Uint8Array | string;
|
||||
encoding?: BufferEncoding;
|
||||
@@ -239,19 +248,140 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
|
||||
private _backlogHead = 0;
|
||||
private static readonly MAX_BACKLOG_SIZE = 10000;
|
||||
|
||||
// Modifiable for tests, defaults based on memory requirements
|
||||
protected MAX_BACKLOG_PAYLOAD_BYTES = 50 * 1024 * 1024;
|
||||
protected MAX_SINGLE_EVENT_PAYLOAD_BYTES = 1 * 1024 * 1024;
|
||||
private _currentBacklogPayloadBytes = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
// Exposed purely for testing to override budget defaults
|
||||
_setBudgetsForTesting(maxBacklog: number, maxSingle: number): void {
|
||||
this.MAX_BACKLOG_PAYLOAD_BYTES = maxBacklog;
|
||||
this.MAX_SINGLE_EVENT_PAYLOAD_BYTES = maxSingle;
|
||||
}
|
||||
|
||||
private _getPayloadBytes(item: EventBacklogItem): number {
|
||||
if (item.event === CoreEvent.Output) {
|
||||
const payload = item.args[0];
|
||||
if (typeof payload.chunk === 'string') {
|
||||
return Buffer.byteLength(payload.chunk, 'utf8');
|
||||
} else {
|
||||
return payload.chunk.byteLength;
|
||||
}
|
||||
} else if (item.event === CoreEvent.ConsoleLog) {
|
||||
const payload = item.args[0];
|
||||
return Buffer.byteLength(payload.content, 'utf8');
|
||||
} else if (item.event === CoreEvent.UserFeedback) {
|
||||
const payload = item.args[0];
|
||||
return Buffer.byteLength(payload.message, 'utf8');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private _truncatePayloadIfNeeded(item: EventBacklogItem): void {
|
||||
const bytes = this._getPayloadBytes(item);
|
||||
if (bytes > this.MAX_SINGLE_EVENT_PAYLOAD_BYTES) {
|
||||
if (item.event === CoreEvent.Output) {
|
||||
const payload = item.args[0];
|
||||
payload.originalByteLength = bytes;
|
||||
payload.truncated = true;
|
||||
if (typeof payload.chunk === 'string') {
|
||||
payload.chunk = Buffer.from(payload.chunk, 'utf8')
|
||||
.subarray(0, this.MAX_SINGLE_EVENT_PAYLOAD_BYTES)
|
||||
.toString('utf8');
|
||||
} else {
|
||||
payload.chunk = payload.chunk.slice(
|
||||
0,
|
||||
this.MAX_SINGLE_EVENT_PAYLOAD_BYTES,
|
||||
);
|
||||
}
|
||||
} else if (item.event === CoreEvent.ConsoleLog) {
|
||||
const payload = item.args[0];
|
||||
payload.originalByteLength = bytes;
|
||||
payload.truncated = true;
|
||||
payload.content = Buffer.from(payload.content, 'utf8')
|
||||
.subarray(0, this.MAX_SINGLE_EVENT_PAYLOAD_BYTES)
|
||||
.toString('utf8');
|
||||
} else if (item.event === CoreEvent.UserFeedback) {
|
||||
const payload = item.args[0];
|
||||
payload.originalByteLength = bytes;
|
||||
payload.truncated = true;
|
||||
payload.message = Buffer.from(payload.message, 'utf8')
|
||||
.subarray(0, this.MAX_SINGLE_EVENT_PAYLOAD_BYTES)
|
||||
.toString('utf8');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _stripPayload(item: EventBacklogItem): void {
|
||||
const bytes = this._getPayloadBytes(item);
|
||||
if (bytes === 0) return;
|
||||
|
||||
if (item.event === CoreEvent.Output) {
|
||||
const payload = item.args[0];
|
||||
payload.omitted = true;
|
||||
if (payload.originalByteLength === undefined) {
|
||||
payload.originalByteLength = bytes;
|
||||
}
|
||||
payload.chunk =
|
||||
typeof payload.chunk === 'string' ? '' : new Uint8Array(0);
|
||||
} else if (item.event === CoreEvent.ConsoleLog) {
|
||||
const payload = item.args[0];
|
||||
payload.omitted = true;
|
||||
if (payload.originalByteLength === undefined) {
|
||||
payload.originalByteLength = bytes;
|
||||
}
|
||||
payload.content = '';
|
||||
} else if (item.event === CoreEvent.UserFeedback) {
|
||||
const payload = item.args[0];
|
||||
payload.omitted = true;
|
||||
if (payload.originalByteLength === undefined) {
|
||||
payload.originalByteLength = bytes;
|
||||
}
|
||||
payload.message = '';
|
||||
}
|
||||
}
|
||||
|
||||
private _emitOrQueue<K extends keyof CoreEvents>(
|
||||
event: K,
|
||||
...args: CoreEvents[K]
|
||||
): void {
|
||||
if (this.listenerCount(event) === 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const item = { event, args } as EventBacklogItem;
|
||||
this._truncatePayloadIfNeeded(item);
|
||||
const itemBytes = this._getPayloadBytes(item);
|
||||
|
||||
// Enforce MAX_BACKLOG_PAYLOAD_BYTES by stripping oldest items
|
||||
let stripIdx = this._backlogHead;
|
||||
while (
|
||||
this._currentBacklogPayloadBytes + itemBytes >
|
||||
this.MAX_BACKLOG_PAYLOAD_BYTES &&
|
||||
stripIdx < this._eventBacklog.length
|
||||
) {
|
||||
const oldItem = this._eventBacklog[stripIdx];
|
||||
if (oldItem) {
|
||||
const oldBytes = this._getPayloadBytes(oldItem);
|
||||
if (oldBytes > 0) {
|
||||
this._stripPayload(oldItem);
|
||||
this._currentBacklogPayloadBytes -= oldBytes;
|
||||
} else {
|
||||
// Item is already stripped, or has no payload. Skip it to preserve event metadata and count.
|
||||
}
|
||||
}
|
||||
stripIdx++;
|
||||
}
|
||||
|
||||
// Enforce MAX_BACKLOG_SIZE
|
||||
const backlogSize = this._eventBacklog.length - this._backlogHead;
|
||||
if (backlogSize >= CoreEventEmitter.MAX_BACKLOG_SIZE) {
|
||||
// Evict oldest entry. Use a head pointer instead of shift() to avoid
|
||||
// O(n) array reindexing on every eviction at capacity.
|
||||
const oldItem = this._eventBacklog[this._backlogHead];
|
||||
if (oldItem) {
|
||||
this._currentBacklogPayloadBytes -= this._getPayloadBytes(oldItem);
|
||||
}
|
||||
(this._eventBacklog as unknown[])[this._backlogHead] = undefined;
|
||||
this._backlogHead++;
|
||||
// Compact once dead entries exceed half capacity to bound memory
|
||||
@@ -260,8 +390,9 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
|
||||
this._backlogHead = 0;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
this._eventBacklog.push({ event, args } as EventBacklogItem);
|
||||
|
||||
this._eventBacklog.push(item);
|
||||
this._currentBacklogPayloadBytes += itemBytes;
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(this.emit as (event: K, ...args: CoreEvents[K]) => boolean)(
|
||||
@@ -270,7 +401,6 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends actionable feedback to the user.
|
||||
* Buffers automatically if the UI hasn't subscribed yet.
|
||||
@@ -409,6 +539,7 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
|
||||
const head = this._backlogHead;
|
||||
this._eventBacklog = [];
|
||||
this._backlogHead = 0;
|
||||
this._currentBacklogPayloadBytes = 0;
|
||||
for (let i = head; i < backlog.length; i++) {
|
||||
const item = backlog[i];
|
||||
if (item === undefined) continue;
|
||||
|
||||
Reference in New Issue
Block a user