mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -07:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d3e4764cc | |||
| 4f8534b223 | |||
| b75ec12185 | |||
| 2009fbbd92 | |||
| 8db2948361 | |||
| 5fa14dbe42 | |||
| e6cd5d208c | |||
| c9d48026c4 | |||
| 34f271504a | |||
| d68100e6bc | |||
| 94e6bf8591 | |||
| fd44718bfe | |||
| f6e21f50fd | |||
| 1725ec346b | |||
| c12fc340c1 | |||
| b6d5374fb7 | |||
| 0082e1ec97 | |||
| 0ed9f1e7f5 | |||
| adf21df71e | |||
| 4dcca1ca10 |
@@ -352,21 +352,6 @@ npm run lint
|
||||
- **Imports:** Pay special attention to import paths. The project uses ESLint to
|
||||
enforce restrictions on relative imports between packages.
|
||||
|
||||
### Project structure
|
||||
|
||||
- `packages/`: Contains the individual sub-packages of the project.
|
||||
- `a2a-server`: A2A server implementation for the Gemini CLI. (Experimental)
|
||||
- `cli/`: The command-line interface.
|
||||
- `core/`: The core backend logic for the Gemini CLI.
|
||||
- `test-utils` Utilities for creating and cleaning temporary file systems for
|
||||
testing.
|
||||
- `vscode-ide-companion/`: The Gemini CLI Companion extension pairs with
|
||||
Gemini CLI.
|
||||
- `docs/`: Contains all project documentation.
|
||||
- `scripts/`: Utility scripts for building, testing, and development tasks.
|
||||
|
||||
For more detailed architecture, see `docs/architecture.md`.
|
||||
|
||||
### Debugging
|
||||
|
||||
#### VS Code
|
||||
|
||||
@@ -314,7 +314,6 @@ gemini
|
||||
|
||||
- [**Headless Mode (Scripting)**](./docs/cli/headless.md) - Use Gemini CLI in
|
||||
automated workflows.
|
||||
- [**Architecture Overview**](./docs/architecture.md) - How Gemini CLI works.
|
||||
- [**IDE Integration**](./docs/ide-integration/index.md) - VS Code companion.
|
||||
- [**Sandboxing & Security**](./docs/cli/sandbox.md) - Safe execution
|
||||
environments.
|
||||
|
||||
+354
-461
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.34.0-preview.4
|
||||
# Preview release: v0.35.0-preview.1
|
||||
|
||||
Released: March 16, 2026
|
||||
Released: March 17, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -13,471 +13,364 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Plan Mode Enabled by Default:** Plan Mode is now enabled out-of-the-box,
|
||||
providing a structured planning workflow and keeping approved plans during
|
||||
chat compression.
|
||||
- **Sandboxing Enhancements:** Added experimental LXC container sandbox support
|
||||
and native gVisor (`runsc`) sandboxing for improved security and isolation.
|
||||
- **Tracker Visualization and Tools:** Introduced CRUD tools and visualization
|
||||
for trackers, along with task tracker strategy improvements.
|
||||
- **Browser Agent Improvements:** Enhanced the browser agent with progress
|
||||
emission, a new automation overlay, and additional integration tests.
|
||||
- **CLI and UI Updates:** Standardized semantic focus colors, polished shell
|
||||
autocomplete rendering, unified keybinding infrastructure, and added custom
|
||||
footer configuration options.
|
||||
- **Subagents & Architecture Enhancements**: Enabled subagents and laid the
|
||||
foundation for subagent tool isolation. Added proxy routing support for remote
|
||||
A2A subagents and integrated `SandboxManager` to sandbox all process-spawning
|
||||
tools.
|
||||
- **CLI & UI Improvements**: Introduced customizable keyboard shortcuts and
|
||||
support for literal character keybindings. Added missing vim mode motions and
|
||||
CJK input support. Enabled code splitting and deferred UI loading for improved
|
||||
performance.
|
||||
- **Context & Tools Optimization**: JIT context loading is now enabled by
|
||||
default with deduplication for project memory. Introduced a model-driven
|
||||
parallel tool scheduler and allowed safe tools to execute concurrently.
|
||||
- **Security & Extensions**: Implemented cryptographic integrity verification
|
||||
for extension updates and added a `disableAlwaysAllow` setting to prevent
|
||||
auto-approvals for enhanced security.
|
||||
- **Plan Mode & Web Fetch Updates**: Added an 'All the above' option for
|
||||
multi-select AskUser questions in Plan Mode. Rolled out Stage 1 and Stage 2
|
||||
security and consistency improvements for the `web_fetch` tool.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 48130eb to release/v0.34.0-preview.3-pr-22665 to patch
|
||||
version v0.34.0-preview.3 and create version 0.34.0-preview.4 by
|
||||
- feat(cli): customizable keyboard shortcuts by @scidomino in
|
||||
[#21945](https://github.com/google-gemini/gemini-cli/pull/21945)
|
||||
- feat(core): Thread `AgentLoopContext` through core. by @joshualitt in
|
||||
[#21944](https://github.com/google-gemini/gemini-cli/pull/21944)
|
||||
- chore(release): bump version to 0.35.0-nightly.20260311.657f19c1f by
|
||||
@gemini-cli-robot in
|
||||
[#22719](https://github.com/google-gemini/gemini-cli/pull/22719)
|
||||
- fix(patch): cherry-pick 24adacd to release/v0.34.0-preview.2-pr-22332 to patch
|
||||
version v0.34.0-preview.2 and create version 0.34.0-preview.3 by
|
||||
@gemini-cli-robot in
|
||||
[#22391](https://github.com/google-gemini/gemini-cli/pull/22391)
|
||||
- fix(patch): cherry-pick 8432bce to release/v0.34.0-preview.1-pr-22069 to patch
|
||||
version v0.34.0-preview.1 and create version 0.34.0-preview.2 by
|
||||
@gemini-cli-robot in
|
||||
[#22205](https://github.com/google-gemini/gemini-cli/pull/22205)
|
||||
- fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148
|
||||
[CONFLICTS] by @gemini-cli-robot in
|
||||
[#22174](https://github.com/google-gemini/gemini-cli/pull/22174)
|
||||
- feat(cli): add chat resume footer on session quit by @lordshashank in
|
||||
[#20667](https://github.com/google-gemini/gemini-cli/pull/20667)
|
||||
- Support bold and other styles in svg snapshots by @jacob314 in
|
||||
[#20937](https://github.com/google-gemini/gemini-cli/pull/20937)
|
||||
- fix(core): increase A2A agent timeout to 30 minutes by @adamfweidman in
|
||||
[#21028](https://github.com/google-gemini/gemini-cli/pull/21028)
|
||||
- Cleanup old branches. by @jacob314 in
|
||||
[#19354](https://github.com/google-gemini/gemini-cli/pull/19354)
|
||||
- chore(release): bump version to 0.34.0-nightly.20260303.34f0c1538 by
|
||||
@gemini-cli-robot in
|
||||
[#21034](https://github.com/google-gemini/gemini-cli/pull/21034)
|
||||
- feat(ui): standardize semantic focus colors and enhance history visibility by
|
||||
@keithguerin in
|
||||
[#20745](https://github.com/google-gemini/gemini-cli/pull/20745)
|
||||
- fix: merge duplicate imports in packages/core (3/4) by @Nixxx19 in
|
||||
[#20928](https://github.com/google-gemini/gemini-cli/pull/20928)
|
||||
- Add extra safety checks for proto pollution by @jacob314 in
|
||||
[#20396](https://github.com/google-gemini/gemini-cli/pull/20396)
|
||||
- feat(core): Add tracker CRUD tools & visualization by @anj-s in
|
||||
[#19489](https://github.com/google-gemini/gemini-cli/pull/19489)
|
||||
- Revert "fix(ui): persist expansion in AskUser dialog when navigating options"
|
||||
by @jacob314 in
|
||||
[#21042](https://github.com/google-gemini/gemini-cli/pull/21042)
|
||||
- Changelog for v0.33.0-preview.0 by @gemini-cli-robot in
|
||||
[#21030](https://github.com/google-gemini/gemini-cli/pull/21030)
|
||||
- fix: model persistence for all scenarios by @sripasg in
|
||||
[#21051](https://github.com/google-gemini/gemini-cli/pull/21051)
|
||||
- chore/release: bump version to 0.34.0-nightly.20260304.28af4e127 by
|
||||
@gemini-cli-robot in
|
||||
[#21054](https://github.com/google-gemini/gemini-cli/pull/21054)
|
||||
- Consistently guard restarts against concurrent auto updates by @scidomino in
|
||||
[#21016](https://github.com/google-gemini/gemini-cli/pull/21016)
|
||||
- Defensive coding to reduce the risk of Maximum update depth errors by
|
||||
@jacob314 in [#20940](https://github.com/google-gemini/gemini-cli/pull/20940)
|
||||
- fix(cli): Polish shell autocomplete rendering to be a little more shell native
|
||||
feeling. by @jacob314 in
|
||||
[#20931](https://github.com/google-gemini/gemini-cli/pull/20931)
|
||||
- Docs: Update plan mode docs by @jkcinouye in
|
||||
[#19682](https://github.com/google-gemini/gemini-cli/pull/19682)
|
||||
- fix(mcp): Notifications/tools/list_changed support not working by @jacob314 in
|
||||
[#21050](https://github.com/google-gemini/gemini-cli/pull/21050)
|
||||
- fix(cli): register extension lifecycle events in DebugProfiler by
|
||||
@fayerman-source in
|
||||
[#20101](https://github.com/google-gemini/gemini-cli/pull/20101)
|
||||
- chore(dev): update vscode settings for typescriptreact by @rohit-4321 in
|
||||
[#19907](https://github.com/google-gemini/gemini-cli/pull/19907)
|
||||
- fix(cli): enable multi-arch docker builds for sandbox by @ru-aish in
|
||||
[#19821](https://github.com/google-gemini/gemini-cli/pull/19821)
|
||||
- Changelog for v0.32.0 by @gemini-cli-robot in
|
||||
[#21033](https://github.com/google-gemini/gemini-cli/pull/21033)
|
||||
- Changelog for v0.33.0-preview.1 by @gemini-cli-robot in
|
||||
[#21058](https://github.com/google-gemini/gemini-cli/pull/21058)
|
||||
- feat(core): improve @scripts/copy_files.js autocomplete to prioritize
|
||||
filenames by @sehoon38 in
|
||||
[#21064](https://github.com/google-gemini/gemini-cli/pull/21064)
|
||||
- feat(sandbox): add experimental LXC container sandbox support by @h30s in
|
||||
[#20735](https://github.com/google-gemini/gemini-cli/pull/20735)
|
||||
- feat(evals): add overall pass rate row to eval nightly summary table by
|
||||
@gundermanc in
|
||||
[#20905](https://github.com/google-gemini/gemini-cli/pull/20905)
|
||||
- feat(telemetry): include language in telemetry and fix accepted lines
|
||||
computation by @gundermanc in
|
||||
[#21126](https://github.com/google-gemini/gemini-cli/pull/21126)
|
||||
- Changelog for v0.32.1 by @gemini-cli-robot in
|
||||
[#21055](https://github.com/google-gemini/gemini-cli/pull/21055)
|
||||
- feat(core): add robustness tests, logging, and metrics for CodeAssistServer
|
||||
SSE parsing by @yunaseoul in
|
||||
[#21013](https://github.com/google-gemini/gemini-cli/pull/21013)
|
||||
- feat: add issue assignee workflow by @kartikangiras in
|
||||
[#21003](https://github.com/google-gemini/gemini-cli/pull/21003)
|
||||
- fix: improve error message when OAuth succeeds but project ID is required by
|
||||
@Nixxx19 in [#21070](https://github.com/google-gemini/gemini-cli/pull/21070)
|
||||
- feat(loop-reduction): implement iterative loop detection and model feedback by
|
||||
@aishaneeshah in
|
||||
[#20763](https://github.com/google-gemini/gemini-cli/pull/20763)
|
||||
- chore(github): require prompt approvers for agent prompt files by @gundermanc
|
||||
in [#20896](https://github.com/google-gemini/gemini-cli/pull/20896)
|
||||
- Docs: Create tools reference by @jkcinouye in
|
||||
[#19470](https://github.com/google-gemini/gemini-cli/pull/19470)
|
||||
- fix(core, a2a-server): prevent hang during OAuth in non-interactive sessions
|
||||
by @spencer426 in
|
||||
[#21045](https://github.com/google-gemini/gemini-cli/pull/21045)
|
||||
- chore(cli): enable deprecated settings removal by default by @yashodipmore in
|
||||
[#20682](https://github.com/google-gemini/gemini-cli/pull/20682)
|
||||
- feat(core): Disable fast ack helper for hints. by @joshualitt in
|
||||
[#21011](https://github.com/google-gemini/gemini-cli/pull/21011)
|
||||
- fix(ui): suppress redundant failure note when tool error note is shown by
|
||||
@NTaylorMullen in
|
||||
[#21078](https://github.com/google-gemini/gemini-cli/pull/21078)
|
||||
- docs: document planning workflows with Conductor example by @jerop in
|
||||
[#21166](https://github.com/google-gemini/gemini-cli/pull/21166)
|
||||
- feat(release): ship esbuild bundle in npm package by @genneth in
|
||||
[#19171](https://github.com/google-gemini/gemini-cli/pull/19171)
|
||||
- fix(extensions): preserve symlinks in extension source path while enforcing
|
||||
folder trust by @galz10 in
|
||||
[#20867](https://github.com/google-gemini/gemini-cli/pull/20867)
|
||||
- fix(cli): defer tool exclusions to policy engine in non-interactive mode by
|
||||
@EricRahm in [#20639](https://github.com/google-gemini/gemini-cli/pull/20639)
|
||||
- fix(ui): removed double padding on rendered content by @devr0306 in
|
||||
[#21029](https://github.com/google-gemini/gemini-cli/pull/21029)
|
||||
- fix(core): truncate excessively long lines in grep search output by
|
||||
@gundermanc in
|
||||
[#21147](https://github.com/google-gemini/gemini-cli/pull/21147)
|
||||
- feat: add custom footer configuration via `/footer` by @jackwotherspoon in
|
||||
[#19001](https://github.com/google-gemini/gemini-cli/pull/19001)
|
||||
- perf(core): fix OOM crash in long-running sessions by @WizardsForgeGames in
|
||||
[#19608](https://github.com/google-gemini/gemini-cli/pull/19608)
|
||||
- refactor(cli): categorize built-in themes into dark/ and light/ directories by
|
||||
@JayadityaGit in
|
||||
[#18634](https://github.com/google-gemini/gemini-cli/pull/18634)
|
||||
- fix(core): explicitly allow codebase_investigator and cli_help in read-only
|
||||
mode by @Adib234 in
|
||||
[#21157](https://github.com/google-gemini/gemini-cli/pull/21157)
|
||||
- test: add browser agent integration tests by @kunal-10-cloud in
|
||||
[#21151](https://github.com/google-gemini/gemini-cli/pull/21151)
|
||||
- fix(cli): fix enabling kitty codes on Windows Terminal by @scidomino in
|
||||
[#21136](https://github.com/google-gemini/gemini-cli/pull/21136)
|
||||
- refactor(core): extract shared OAuth flow primitives from MCPOAuthProvider by
|
||||
@SandyTao520 in
|
||||
[#20895](https://github.com/google-gemini/gemini-cli/pull/20895)
|
||||
- fix(ui): add partial output to cancelled shell UI by @devr0306 in
|
||||
[#21178](https://github.com/google-gemini/gemini-cli/pull/21178)
|
||||
- fix(cli): replace hardcoded keybinding strings with dynamic formatters by
|
||||
@scidomino in [#21159](https://github.com/google-gemini/gemini-cli/pull/21159)
|
||||
- DOCS: Update quota and pricing page by @g-samroberts in
|
||||
[#21194](https://github.com/google-gemini/gemini-cli/pull/21194)
|
||||
- feat(telemetry): implement Clearcut logging for startup statistics by
|
||||
@yunaseoul in [#21172](https://github.com/google-gemini/gemini-cli/pull/21172)
|
||||
- feat(triage): add area/documentation to issue triage by @g-samroberts in
|
||||
[#21222](https://github.com/google-gemini/gemini-cli/pull/21222)
|
||||
- Fix so shell calls are formatted by @jacob314 in
|
||||
[#21237](https://github.com/google-gemini/gemini-cli/pull/21237)
|
||||
- feat(cli): add native gVisor (runsc) sandboxing support by @Zheyuan-Lin in
|
||||
[#21062](https://github.com/google-gemini/gemini-cli/pull/21062)
|
||||
- docs: use absolute paths for internal links in plan-mode.md by @jerop in
|
||||
[#21299](https://github.com/google-gemini/gemini-cli/pull/21299)
|
||||
- fix(core): prevent unhandled AbortError crash during stream loop detection by
|
||||
@7hokerz in [#21123](https://github.com/google-gemini/gemini-cli/pull/21123)
|
||||
- fix:reorder env var redaction checks to scan values first by @kartikangiras in
|
||||
[#21059](https://github.com/google-gemini/gemini-cli/pull/21059)
|
||||
- fix(acp): rename --experimental-acp to --acp & remove Zed-specific refrences
|
||||
by @skeshive in
|
||||
[#21171](https://github.com/google-gemini/gemini-cli/pull/21171)
|
||||
- feat(core): fallback to 2.5 models with no access for toolcalls by @sehoon38
|
||||
in [#21283](https://github.com/google-gemini/gemini-cli/pull/21283)
|
||||
- test(core): improve testing for API request/response parsing by @sehoon38 in
|
||||
[#21227](https://github.com/google-gemini/gemini-cli/pull/21227)
|
||||
- docs(links): update docs-writer skill and fix broken link by @g-samroberts in
|
||||
[#21314](https://github.com/google-gemini/gemini-cli/pull/21314)
|
||||
- Fix code colorizer ansi escape bug. by @jacob314 in
|
||||
[#21321](https://github.com/google-gemini/gemini-cli/pull/21321)
|
||||
- remove wildcard behavior on keybindings by @scidomino in
|
||||
[#21315](https://github.com/google-gemini/gemini-cli/pull/21315)
|
||||
- feat(acp): Add support for AI Gateway auth by @skeshive in
|
||||
[#21305](https://github.com/google-gemini/gemini-cli/pull/21305)
|
||||
- fix(theme): improve theme color contrast for macOS Terminal.app by @clocky in
|
||||
[#21175](https://github.com/google-gemini/gemini-cli/pull/21175)
|
||||
- feat (core): Implement tracker related SI changes by @anj-s in
|
||||
[#19964](https://github.com/google-gemini/gemini-cli/pull/19964)
|
||||
- Changelog for v0.33.0-preview.2 by @gemini-cli-robot in
|
||||
[#21333](https://github.com/google-gemini/gemini-cli/pull/21333)
|
||||
- Changelog for v0.33.0-preview.3 by @gemini-cli-robot in
|
||||
[#21347](https://github.com/google-gemini/gemini-cli/pull/21347)
|
||||
- docs: format release times as HH:MM UTC by @pavan-sh in
|
||||
[#20726](https://github.com/google-gemini/gemini-cli/pull/20726)
|
||||
- fix(cli): implement --all flag for extensions uninstall by @sehoon38 in
|
||||
[#21319](https://github.com/google-gemini/gemini-cli/pull/21319)
|
||||
- docs: fix incorrect relative links to command reference by @kanywst in
|
||||
[#20964](https://github.com/google-gemini/gemini-cli/pull/20964)
|
||||
- documentiong ensures ripgrep by @Jatin24062005 in
|
||||
[#21298](https://github.com/google-gemini/gemini-cli/pull/21298)
|
||||
- fix(core): handle AbortError thrown during processTurn by @MumuTW in
|
||||
[#21296](https://github.com/google-gemini/gemini-cli/pull/21296)
|
||||
- docs(cli): clarify ! command output visibility in shell commands tutorial by
|
||||
@MohammedADev in
|
||||
[#21041](https://github.com/google-gemini/gemini-cli/pull/21041)
|
||||
- fix: logic for task tracker strategy and remove tracker tools by @anj-s in
|
||||
[#21355](https://github.com/google-gemini/gemini-cli/pull/21355)
|
||||
- fix(partUtils): display media type and size for inline data parts by @Aboudjem
|
||||
in [#21358](https://github.com/google-gemini/gemini-cli/pull/21358)
|
||||
- Fix(accessibility): add screen reader support to RewindViewer by @Famous077 in
|
||||
[#20750](https://github.com/google-gemini/gemini-cli/pull/20750)
|
||||
- fix(hooks): propagate stopHookActive in AfterAgent retry path (#20426) by
|
||||
@Aarchi-07 in [#20439](https://github.com/google-gemini/gemini-cli/pull/20439)
|
||||
- fix(core): deduplicate GEMINI.md files by device/inode on case-insensitive
|
||||
filesystems (#19904) by @Nixxx19 in
|
||||
[#19915](https://github.com/google-gemini/gemini-cli/pull/19915)
|
||||
- feat(core): add concurrency safety guidance for subagent delegation (#17753)
|
||||
by @abhipatel12 in
|
||||
[#21278](https://github.com/google-gemini/gemini-cli/pull/21278)
|
||||
- feat(ui): dynamically generate all keybinding hints by @scidomino in
|
||||
[#21346](https://github.com/google-gemini/gemini-cli/pull/21346)
|
||||
- feat(core): implement unified KeychainService and migrate token storage by
|
||||
@ehedlund in [#21344](https://github.com/google-gemini/gemini-cli/pull/21344)
|
||||
- fix(cli): gracefully handle --resume when no sessions exist by @SandyTao520 in
|
||||
[#21429](https://github.com/google-gemini/gemini-cli/pull/21429)
|
||||
- fix(plan): keep approved plan during chat compression by @ruomengz in
|
||||
[#21284](https://github.com/google-gemini/gemini-cli/pull/21284)
|
||||
- feat(core): implement generic CacheService and optimize setupUser by @sehoon38
|
||||
in [#21374](https://github.com/google-gemini/gemini-cli/pull/21374)
|
||||
- Update quota and pricing documentation with subscription tiers by @srithreepo
|
||||
in [#21351](https://github.com/google-gemini/gemini-cli/pull/21351)
|
||||
- fix(core): append correct OTLP paths for HTTP exporters by
|
||||
@sebastien-prudhomme in
|
||||
[#16836](https://github.com/google-gemini/gemini-cli/pull/16836)
|
||||
- Changelog for v0.33.0-preview.4 by @gemini-cli-robot in
|
||||
[#21354](https://github.com/google-gemini/gemini-cli/pull/21354)
|
||||
- feat(cli): implement dot-prefixing for slash command conflicts by @ehedlund in
|
||||
[#20979](https://github.com/google-gemini/gemini-cli/pull/20979)
|
||||
- refactor(core): standardize MCP tool naming to mcp\_ FQN format by
|
||||
@abhipatel12 in
|
||||
[#21425](https://github.com/google-gemini/gemini-cli/pull/21425)
|
||||
- feat(cli): hide gemma settings from display and mark as experimental by
|
||||
@abhipatel12 in
|
||||
[#21471](https://github.com/google-gemini/gemini-cli/pull/21471)
|
||||
- feat(skills): refine string-reviewer guidelines and description by @clocky in
|
||||
[#20368](https://github.com/google-gemini/gemini-cli/pull/20368)
|
||||
- fix(core): whitelist TERM and COLORTERM in environment sanitization by
|
||||
@deadsmash07 in
|
||||
[#20514](https://github.com/google-gemini/gemini-cli/pull/20514)
|
||||
- fix(billing): fix overage strategy lifecycle and settings integration by
|
||||
@gsquared94 in
|
||||
[#21236](https://github.com/google-gemini/gemini-cli/pull/21236)
|
||||
- fix: expand paste placeholders in TextInput on submit by @Jefftree in
|
||||
[#19946](https://github.com/google-gemini/gemini-cli/pull/19946)
|
||||
- fix(core): add in-memory cache to ChatRecordingService to prevent OOM by
|
||||
@SandyTao520 in
|
||||
[#21502](https://github.com/google-gemini/gemini-cli/pull/21502)
|
||||
- feat(cli): overhaul thinking UI by @keithguerin in
|
||||
[#18725](https://github.com/google-gemini/gemini-cli/pull/18725)
|
||||
- fix(ui): unify Ctrl+O expansion hint experience across buffer modes by
|
||||
@jwhelangoog in
|
||||
[#21474](https://github.com/google-gemini/gemini-cli/pull/21474)
|
||||
- fix(cli): correct shell height reporting by @jacob314 in
|
||||
[#21492](https://github.com/google-gemini/gemini-cli/pull/21492)
|
||||
- Make test suite pass when the GEMINI_SYSTEM_MD env variable or
|
||||
GEMINI_WRITE_SYSTEM_MD variable happens to be set locally/ by @jacob314 in
|
||||
[#21480](https://github.com/google-gemini/gemini-cli/pull/21480)
|
||||
- Disallow underspecified types by @gundermanc in
|
||||
[#21485](https://github.com/google-gemini/gemini-cli/pull/21485)
|
||||
- refactor(cli): standardize on 'reload' verb for all components by @keithguerin
|
||||
in [#20654](https://github.com/google-gemini/gemini-cli/pull/20654)
|
||||
- feat(cli): Invert quota language to 'percent used' by @keithguerin in
|
||||
[#20100](https://github.com/google-gemini/gemini-cli/pull/20100)
|
||||
- Docs: Add documentation for notifications (experimental)(macOS) by @jkcinouye
|
||||
in [#21163](https://github.com/google-gemini/gemini-cli/pull/21163)
|
||||
- Code review comments as a pr by @jacob314 in
|
||||
[#21209](https://github.com/google-gemini/gemini-cli/pull/21209)
|
||||
- feat(cli): unify /chat and /resume command UX by @LyalinDotCom in
|
||||
[#20256](https://github.com/google-gemini/gemini-cli/pull/20256)
|
||||
- docs: fix typo 'allowslisted' -> 'allowlisted' in mcp-server.md by
|
||||
[#21966](https://github.com/google-gemini/gemini-cli/pull/21966)
|
||||
- refactor(a2a): remove legacy CoreToolScheduler by @adamfweidman in
|
||||
[#21955](https://github.com/google-gemini/gemini-cli/pull/21955)
|
||||
- feat(ui): add missing vim mode motions (X, ~, r, f/F/t/T, df/dt and friends)
|
||||
by @aanari in [#21932](https://github.com/google-gemini/gemini-cli/pull/21932)
|
||||
- Feat/retry fetch notifications by @aishaneeshah in
|
||||
[#21813](https://github.com/google-gemini/gemini-cli/pull/21813)
|
||||
- fix(core): remove OAuth check from handleFallback and clean up stray file by
|
||||
@sehoon38 in [#21962](https://github.com/google-gemini/gemini-cli/pull/21962)
|
||||
- feat(cli): support literal character keybindings and extended Kitty protocol
|
||||
keys by @scidomino in
|
||||
[#21972](https://github.com/google-gemini/gemini-cli/pull/21972)
|
||||
- fix(ui): clamp cursor to last char after all NORMAL mode deletes by @aanari in
|
||||
[#21973](https://github.com/google-gemini/gemini-cli/pull/21973)
|
||||
- test(core): add missing tests for prompts/utils.ts by @krrishverma1805-web in
|
||||
[#19941](https://github.com/google-gemini/gemini-cli/pull/19941)
|
||||
- fix(cli): allow scrolling keys in copy mode (Ctrl+S selection mode) by
|
||||
@nsalerni in [#19933](https://github.com/google-gemini/gemini-cli/pull/19933)
|
||||
- docs(cli): add custom keybinding documentation by @scidomino in
|
||||
[#21980](https://github.com/google-gemini/gemini-cli/pull/21980)
|
||||
- docs: fix misleading YOLO mode description in defaultApprovalMode by
|
||||
@Gyanranjan-Priyam in
|
||||
[#21665](https://github.com/google-gemini/gemini-cli/pull/21665)
|
||||
- fix(core): display actual graph output in tracker_visualize tool by @anj-s in
|
||||
[#21455](https://github.com/google-gemini/gemini-cli/pull/21455)
|
||||
- fix(core): sanitize SSE-corrupted JSON and domain strings in error
|
||||
classification by @gsquared94 in
|
||||
[#21702](https://github.com/google-gemini/gemini-cli/pull/21702)
|
||||
- Docs: Make documentation links relative by @diodesign in
|
||||
[#21490](https://github.com/google-gemini/gemini-cli/pull/21490)
|
||||
- feat(cli): expose /tools desc as explicit subcommand for discoverability by
|
||||
@aworki in [#21241](https://github.com/google-gemini/gemini-cli/pull/21241)
|
||||
- feat(cli): add /compact alias for /compress command by @jackwotherspoon in
|
||||
[#21711](https://github.com/google-gemini/gemini-cli/pull/21711)
|
||||
- feat(plan): enable Plan Mode by default by @jerop in
|
||||
[#21713](https://github.com/google-gemini/gemini-cli/pull/21713)
|
||||
- feat(core): Introduce `AgentLoopContext`. by @joshualitt in
|
||||
[#21198](https://github.com/google-gemini/gemini-cli/pull/21198)
|
||||
- fix(core): resolve symlinks for non-existent paths during validation by
|
||||
@Adib234 in [#21487](https://github.com/google-gemini/gemini-cli/pull/21487)
|
||||
- docs: document tool exclusion from memory via deny policy by @Abhijit-2592 in
|
||||
[#21428](https://github.com/google-gemini/gemini-cli/pull/21428)
|
||||
- perf(core): cache loadApiKey to reduce redundant keychain access by @sehoon38
|
||||
in [#21520](https://github.com/google-gemini/gemini-cli/pull/21520)
|
||||
- feat(cli): implement /upgrade command by @sehoon38 in
|
||||
[#21511](https://github.com/google-gemini/gemini-cli/pull/21511)
|
||||
- Feat/browser agent progress emission by @kunal-10-cloud in
|
||||
[#21218](https://github.com/google-gemini/gemini-cli/pull/21218)
|
||||
- fix(settings): display objects as JSON instead of [object Object] by
|
||||
@Zheyuan-Lin in
|
||||
[#21458](https://github.com/google-gemini/gemini-cli/pull/21458)
|
||||
- Unmarshall update by @DavidAPierce in
|
||||
[#21721](https://github.com/google-gemini/gemini-cli/pull/21721)
|
||||
- Update mcp's list function to check for disablement. by @DavidAPierce in
|
||||
[#21148](https://github.com/google-gemini/gemini-cli/pull/21148)
|
||||
- robustness(core): static checks to validate history is immutable by @jacob314
|
||||
in [#21228](https://github.com/google-gemini/gemini-cli/pull/21228)
|
||||
- refactor(cli): better react patterns for BaseSettingsDialog by @psinha40898 in
|
||||
[#21206](https://github.com/google-gemini/gemini-cli/pull/21206)
|
||||
- feat(security): implement robust IP validation and safeFetch foundation by
|
||||
@alisa-alisa in
|
||||
[#21401](https://github.com/google-gemini/gemini-cli/pull/21401)
|
||||
- feat(core): improve subagent result display by @joshualitt in
|
||||
[#20378](https://github.com/google-gemini/gemini-cli/pull/20378)
|
||||
- docs: fix broken markdown syntax and anchor links in /tools by @campox747 in
|
||||
[#20902](https://github.com/google-gemini/gemini-cli/pull/20902)
|
||||
- feat(policy): support subagent-specific policies in TOML by @akh64bit in
|
||||
[#21431](https://github.com/google-gemini/gemini-cli/pull/21431)
|
||||
- Add script to speed up reviewing PRs adding a worktree. by @jacob314 in
|
||||
[#21748](https://github.com/google-gemini/gemini-cli/pull/21748)
|
||||
- fix(core): prevent infinite recursion in symlink resolution by @Adib234 in
|
||||
[#21750](https://github.com/google-gemini/gemini-cli/pull/21750)
|
||||
- fix(docs): fix headless mode docs by @ame2en in
|
||||
[#21287](https://github.com/google-gemini/gemini-cli/pull/21287)
|
||||
- feat/redesign header compact by @jacob314 in
|
||||
[#20922](https://github.com/google-gemini/gemini-cli/pull/20922)
|
||||
- refactor: migrate to useKeyMatchers hook by @scidomino in
|
||||
[#21753](https://github.com/google-gemini/gemini-cli/pull/21753)
|
||||
- perf(cli): cache loadSettings to reduce redundant disk I/O at startup by
|
||||
@sehoon38 in [#21521](https://github.com/google-gemini/gemini-cli/pull/21521)
|
||||
- fix(core): resolve Windows line ending and path separation bugs across CLI by
|
||||
@muhammadusman586 in
|
||||
[#21068](https://github.com/google-gemini/gemini-cli/pull/21068)
|
||||
- docs: fix heading formatting in commands.md and phrasing in tools-api.md by
|
||||
@campox747 in [#20679](https://github.com/google-gemini/gemini-cli/pull/20679)
|
||||
- refactor(ui): unify keybinding infrastructure and support string
|
||||
initialization by @scidomino in
|
||||
[#21776](https://github.com/google-gemini/gemini-cli/pull/21776)
|
||||
- Add support for updating extension sources and names by @chrstnb in
|
||||
[#21715](https://github.com/google-gemini/gemini-cli/pull/21715)
|
||||
- fix(core): handle GUI editor non-zero exit codes gracefully by @reyyanxahmed
|
||||
in [#20376](https://github.com/google-gemini/gemini-cli/pull/20376)
|
||||
- fix(core): destroy PTY on kill() and exception to prevent fd leak by @nbardy
|
||||
in [#21693](https://github.com/google-gemini/gemini-cli/pull/21693)
|
||||
- fix(docs): update theme screenshots and add missing themes by @ashmod in
|
||||
[#20689](https://github.com/google-gemini/gemini-cli/pull/20689)
|
||||
- refactor(cli): rename 'return' key to 'enter' internally by @scidomino in
|
||||
[#21796](https://github.com/google-gemini/gemini-cli/pull/21796)
|
||||
- build(release): restrict npm bundling to non-stable tags by @sehoon38 in
|
||||
[#21821](https://github.com/google-gemini/gemini-cli/pull/21821)
|
||||
- fix(core): override toolRegistry property for sub-agent schedulers by
|
||||
@gsquared94 in
|
||||
[#21766](https://github.com/google-gemini/gemini-cli/pull/21766)
|
||||
- fix(cli): make footer items equally spaced by @jacob314 in
|
||||
[#21843](https://github.com/google-gemini/gemini-cli/pull/21843)
|
||||
- docs: clarify global policy rules application in plan mode by @jerop in
|
||||
[#21864](https://github.com/google-gemini/gemini-cli/pull/21864)
|
||||
- fix(core): ensure correct flash model steering in plan mode implementation
|
||||
phase by @jerop in
|
||||
[#21871](https://github.com/google-gemini/gemini-cli/pull/21871)
|
||||
- fix(core): update @a2a-js/sdk to 0.3.11 by @adamfweidman in
|
||||
[#21875](https://github.com/google-gemini/gemini-cli/pull/21875)
|
||||
- refactor(core): improve API response error logging when retry by @yunaseoul in
|
||||
[#21784](https://github.com/google-gemini/gemini-cli/pull/21784)
|
||||
- fix(ui): handle headless execution in credits and upgrade dialogs by
|
||||
@gsquared94 in
|
||||
[#21850](https://github.com/google-gemini/gemini-cli/pull/21850)
|
||||
- fix(core): treat retryable errors with >5 min delay as terminal quota errors
|
||||
by @gsquared94 in
|
||||
[#21881](https://github.com/google-gemini/gemini-cli/pull/21881)
|
||||
- feat(telemetry): add specific PR, issue, and custom tracking IDs for GitHub
|
||||
Actions by @cocosheng-g in
|
||||
[#21129](https://github.com/google-gemini/gemini-cli/pull/21129)
|
||||
- feat(core): add OAuth2 Authorization Code auth provider for A2A agents by
|
||||
@SandyTao520 in
|
||||
[#21496](https://github.com/google-gemini/gemini-cli/pull/21496)
|
||||
- feat(cli): give visibility to /tools list command in the TUI and follow the
|
||||
subcommand pattern of other commands by @JayadityaGit in
|
||||
[#21213](https://github.com/google-gemini/gemini-cli/pull/21213)
|
||||
- Handle dirty worktrees better and warn about running scripts/review.sh on
|
||||
untrusted code. by @jacob314 in
|
||||
[#21791](https://github.com/google-gemini/gemini-cli/pull/21791)
|
||||
- feat(policy): support auto-add to policy by default and scoped persistence by
|
||||
[#21878](https://github.com/google-gemini/gemini-cli/pull/21878)
|
||||
- fix: clean up /clear and /resume by @jackwotherspoon in
|
||||
[#22007](https://github.com/google-gemini/gemini-cli/pull/22007)
|
||||
- fix(core)#20941: reap orphaned descendant processes on PTY abort by @manavmax
|
||||
in [#21124](https://github.com/google-gemini/gemini-cli/pull/21124)
|
||||
- fix(core): update language detection to use LSP 3.18 identifiers by @yunaseoul
|
||||
in [#21931](https://github.com/google-gemini/gemini-cli/pull/21931)
|
||||
- feat(cli): support removing keybindings via '-' prefix by @scidomino in
|
||||
[#22042](https://github.com/google-gemini/gemini-cli/pull/22042)
|
||||
- feat(policy): add --admin-policy flag for supplemental admin policies by
|
||||
@galz10 in [#20360](https://github.com/google-gemini/gemini-cli/pull/20360)
|
||||
- merge duplicate imports packages/cli/src subtask1 by @Nixxx19 in
|
||||
[#22040](https://github.com/google-gemini/gemini-cli/pull/22040)
|
||||
- perf(core): parallelize user quota and experiments fetching in refreshAuth by
|
||||
@sehoon38 in [#21648](https://github.com/google-gemini/gemini-cli/pull/21648)
|
||||
- Changelog for v0.34.0-preview.0 by @gemini-cli-robot in
|
||||
[#21965](https://github.com/google-gemini/gemini-cli/pull/21965)
|
||||
- Changelog for v0.33.0 by @gemini-cli-robot in
|
||||
[#21967](https://github.com/google-gemini/gemini-cli/pull/21967)
|
||||
- fix(core): handle EISDIR in robustRealpath on Windows by @sehoon38 in
|
||||
[#21984](https://github.com/google-gemini/gemini-cli/pull/21984)
|
||||
- feat(core): include initiationMethod in conversation interaction telemetry by
|
||||
@yunaseoul in [#22054](https://github.com/google-gemini/gemini-cli/pull/22054)
|
||||
- feat(ui): add vim yank/paste (y/p/P) with unnamed register by @aanari in
|
||||
[#22026](https://github.com/google-gemini/gemini-cli/pull/22026)
|
||||
- fix(core): enable numerical routing for api key users by @sehoon38 in
|
||||
[#21977](https://github.com/google-gemini/gemini-cli/pull/21977)
|
||||
- feat(telemetry): implement retry attempt telemetry for network related retries
|
||||
by @aishaneeshah in
|
||||
[#22027](https://github.com/google-gemini/gemini-cli/pull/22027)
|
||||
- fix(policy): remove unnecessary escapeRegex from pattern builders by
|
||||
@spencer426 in
|
||||
[#20361](https://github.com/google-gemini/gemini-cli/pull/20361)
|
||||
- fix(core): handle AbortError when ESC cancels tool execution by @PrasannaPal21
|
||||
in [#20863](https://github.com/google-gemini/gemini-cli/pull/20863)
|
||||
- fix(release): Improve Patch Release Workflow Comments: Clearer Approval
|
||||
Guidance by @jerop in
|
||||
[#21894](https://github.com/google-gemini/gemini-cli/pull/21894)
|
||||
- docs: clarify telemetry setup and comprehensive data map by @jerop in
|
||||
[#21879](https://github.com/google-gemini/gemini-cli/pull/21879)
|
||||
- feat(core): add per-model token usage to stream-json output by @yongruilin in
|
||||
[#21839](https://github.com/google-gemini/gemini-cli/pull/21839)
|
||||
- docs: remove experimental badge from plan mode in sidebar by @jerop in
|
||||
[#21906](https://github.com/google-gemini/gemini-cli/pull/21906)
|
||||
- fix(cli): prevent race condition in loop detection retry by @skyvanguard in
|
||||
[#17916](https://github.com/google-gemini/gemini-cli/pull/17916)
|
||||
- Add behavioral evals for tracker by @anj-s in
|
||||
[#20069](https://github.com/google-gemini/gemini-cli/pull/20069)
|
||||
- fix(auth): update terminology to 'sign in' and 'sign out' by @clocky in
|
||||
[#20892](https://github.com/google-gemini/gemini-cli/pull/20892)
|
||||
- docs(mcp): standardize mcp tool fqn documentation by @abhipatel12 in
|
||||
[#21664](https://github.com/google-gemini/gemini-cli/pull/21664)
|
||||
- fix(ui): prevent empty tool-group border stubs after filtering by @Aaxhirrr in
|
||||
[#21852](https://github.com/google-gemini/gemini-cli/pull/21852)
|
||||
- make command names consistent by @scidomino in
|
||||
[#21907](https://github.com/google-gemini/gemini-cli/pull/21907)
|
||||
- refactor: remove agent_card_requires_auth config flag by @adamfweidman in
|
||||
[#21914](https://github.com/google-gemini/gemini-cli/pull/21914)
|
||||
- feat(a2a): implement standardized normalization and streaming reassembly by
|
||||
@alisa-alisa in
|
||||
[#21402](https://github.com/google-gemini/gemini-cli/pull/21402)
|
||||
- feat(cli): enable skill activation via slash commands by @NTaylorMullen in
|
||||
[#21758](https://github.com/google-gemini/gemini-cli/pull/21758)
|
||||
- docs(cli): mention per-model token usage in stream-json result event by
|
||||
@yongruilin in
|
||||
[#21908](https://github.com/google-gemini/gemini-cli/pull/21908)
|
||||
- fix(plan): prevent plan truncation in approval dialog by supporting
|
||||
unconstrained heights by @Adib234 in
|
||||
[#21037](https://github.com/google-gemini/gemini-cli/pull/21037)
|
||||
- feat(a2a): switch from callback-based to event-driven tool scheduler by
|
||||
@cocosheng-g in
|
||||
[#21467](https://github.com/google-gemini/gemini-cli/pull/21467)
|
||||
- feat(voice): implement speech-friendly response formatter by @Solventerritory
|
||||
in [#20989](https://github.com/google-gemini/gemini-cli/pull/20989)
|
||||
- feat: add pulsating blue border automation overlay to browser agent by
|
||||
@kunal-10-cloud in
|
||||
[#21173](https://github.com/google-gemini/gemini-cli/pull/21173)
|
||||
- Add extensionRegistryURI setting to change where the registry is read from by
|
||||
@kevinjwang1 in
|
||||
[#20463](https://github.com/google-gemini/gemini-cli/pull/20463)
|
||||
- fix: patch gaxios v7 Array.toString() stream corruption by @gsquared94 in
|
||||
[#21884](https://github.com/google-gemini/gemini-cli/pull/21884)
|
||||
- fix: prevent hangs in non-interactive mode and improve agent guidance by
|
||||
@cocosheng-g in
|
||||
[#20893](https://github.com/google-gemini/gemini-cli/pull/20893)
|
||||
- Add ExtensionDetails dialog and support install by @chrstnb in
|
||||
[#20845](https://github.com/google-gemini/gemini-cli/pull/20845)
|
||||
- chore/release: bump version to 0.34.0-nightly.20260310.4653b126f by
|
||||
@gemini-cli-robot in
|
||||
[#21816](https://github.com/google-gemini/gemini-cli/pull/21816)
|
||||
- Changelog for v0.33.0-preview.13 by @gemini-cli-robot in
|
||||
[#21927](https://github.com/google-gemini/gemini-cli/pull/21927)
|
||||
- fix(cli): stabilize prompt layout to prevent jumping when typing by
|
||||
[#21921](https://github.com/google-gemini/gemini-cli/pull/21921)
|
||||
- fix(core): preserve dynamic tool descriptions on session resume by @sehoon38
|
||||
in [#18835](https://github.com/google-gemini/gemini-cli/pull/18835)
|
||||
- chore: allow 'gemini-3.1' in sensitive keyword linter by @scidomino in
|
||||
[#22065](https://github.com/google-gemini/gemini-cli/pull/22065)
|
||||
- feat(core): support custom base URL via env vars by @junaiddshaukat in
|
||||
[#21561](https://github.com/google-gemini/gemini-cli/pull/21561)
|
||||
- merge duplicate imports packages/cli/src subtask2 by @Nixxx19 in
|
||||
[#22051](https://github.com/google-gemini/gemini-cli/pull/22051)
|
||||
- fix(core): silently retry API errors up to 3 times before halting session by
|
||||
@spencer426 in
|
||||
[#21989](https://github.com/google-gemini/gemini-cli/pull/21989)
|
||||
- feat(core): simplify subagent success UI and improve early termination display
|
||||
by @abhipatel12 in
|
||||
[#21917](https://github.com/google-gemini/gemini-cli/pull/21917)
|
||||
- merge duplicate imports packages/cli/src subtask3 by @Nixxx19 in
|
||||
[#22056](https://github.com/google-gemini/gemini-cli/pull/22056)
|
||||
- fix(hooks): fix BeforeAgent/AfterAgent inconsistencies (#18514) by @krishdef7
|
||||
in [#21383](https://github.com/google-gemini/gemini-cli/pull/21383)
|
||||
- feat(core): implement SandboxManager interface and config schema by @galz10 in
|
||||
[#21774](https://github.com/google-gemini/gemini-cli/pull/21774)
|
||||
- docs: document npm deprecation warnings as safe to ignore by @h30s in
|
||||
[#20692](https://github.com/google-gemini/gemini-cli/pull/20692)
|
||||
- fix: remove status/need-triage from maintainer-only issues by @SandyTao520 in
|
||||
[#22044](https://github.com/google-gemini/gemini-cli/pull/22044)
|
||||
- fix(core): propagate subagent context to policy engine by @NTaylorMullen in
|
||||
[#22086](https://github.com/google-gemini/gemini-cli/pull/22086)
|
||||
- fix(cli): resolve skill uninstall failure when skill name is updated by
|
||||
@NTaylorMullen in
|
||||
[#21081](https://github.com/google-gemini/gemini-cli/pull/21081)
|
||||
- fix: preserve prompt text when cancelling streaming by @Nixxx19 in
|
||||
[#21103](https://github.com/google-gemini/gemini-cli/pull/21103)
|
||||
- fix: robust UX for remote agent errors by @Shyam-Raghuwanshi in
|
||||
[#20307](https://github.com/google-gemini/gemini-cli/pull/20307)
|
||||
- feat: implement background process logging and cleanup by @galz10 in
|
||||
[#21189](https://github.com/google-gemini/gemini-cli/pull/21189)
|
||||
- Changelog for v0.33.0-preview.14 by @gemini-cli-robot in
|
||||
[#21938](https://github.com/google-gemini/gemini-cli/pull/21938)
|
||||
[#22085](https://github.com/google-gemini/gemini-cli/pull/22085)
|
||||
- docs(plan): clarify interactive plan editing with Ctrl+X by @Adib234 in
|
||||
[#22076](https://github.com/google-gemini/gemini-cli/pull/22076)
|
||||
- fix(policy): ensure user policies are loaded when policyPaths is empty by
|
||||
@NTaylorMullen in
|
||||
[#22090](https://github.com/google-gemini/gemini-cli/pull/22090)
|
||||
- Docs: Add documentation for model steering (experimental). by @jkcinouye in
|
||||
[#21154](https://github.com/google-gemini/gemini-cli/pull/21154)
|
||||
- Add issue for automated changelogs by @g-samroberts in
|
||||
[#21912](https://github.com/google-gemini/gemini-cli/pull/21912)
|
||||
- fix(core): secure argsPattern and revert WEB_FETCH_TOOL_NAME escalation by
|
||||
@spencer426 in
|
||||
[#22104](https://github.com/google-gemini/gemini-cli/pull/22104)
|
||||
- feat(core): differentiate User-Agent for a2a-server and ACP clients by
|
||||
@bdmorgan in [#22059](https://github.com/google-gemini/gemini-cli/pull/22059)
|
||||
- refactor(core): extract ExecutionLifecycleService for tool backgrounding by
|
||||
@adamfweidman in
|
||||
[#21717](https://github.com/google-gemini/gemini-cli/pull/21717)
|
||||
- feat: Display pending and confirming tool calls by @sripasg in
|
||||
[#22106](https://github.com/google-gemini/gemini-cli/pull/22106)
|
||||
- feat(browser): implement input blocker overlay during automation by
|
||||
@kunal-10-cloud in
|
||||
[#21132](https://github.com/google-gemini/gemini-cli/pull/21132)
|
||||
- fix: register themes on extension load not start by @jackwotherspoon in
|
||||
[#22148](https://github.com/google-gemini/gemini-cli/pull/22148)
|
||||
- feat(ui): Do not show Ultra users /upgrade hint (#22154) by @sehoon38 in
|
||||
[#22156](https://github.com/google-gemini/gemini-cli/pull/22156)
|
||||
- chore: remove unnecessary log for themes by @jackwotherspoon in
|
||||
[#22165](https://github.com/google-gemini/gemini-cli/pull/22165)
|
||||
- fix(core): resolve MCP tool FQN validation, schema export, and wildcards in
|
||||
subagents by @abhipatel12 in
|
||||
[#22069](https://github.com/google-gemini/gemini-cli/pull/22069)
|
||||
- fix(cli): validate --model argument at startup by @JaisalJain in
|
||||
[#21393](https://github.com/google-gemini/gemini-cli/pull/21393)
|
||||
- fix(core): handle policy ALLOW for exit_plan_mode by @backnotprop in
|
||||
[#21802](https://github.com/google-gemini/gemini-cli/pull/21802)
|
||||
- feat(telemetry): add Clearcut instrumentation for AI credits billing events by
|
||||
@gsquared94 in
|
||||
[#22153](https://github.com/google-gemini/gemini-cli/pull/22153)
|
||||
- feat(core): add google credentials provider for remote agents by @adamfweidman
|
||||
in [#21024](https://github.com/google-gemini/gemini-cli/pull/21024)
|
||||
- test(cli): add integration test for node deprecation warnings by @Nixxx19 in
|
||||
[#20215](https://github.com/google-gemini/gemini-cli/pull/20215)
|
||||
- feat(cli): allow safe tools to execute concurrently while agent is busy by
|
||||
@spencer426 in
|
||||
[#21988](https://github.com/google-gemini/gemini-cli/pull/21988)
|
||||
- feat(core): implement model-driven parallel tool scheduler by @abhipatel12 in
|
||||
[#21933](https://github.com/google-gemini/gemini-cli/pull/21933)
|
||||
- update vulnerable deps by @scidomino in
|
||||
[#22180](https://github.com/google-gemini/gemini-cli/pull/22180)
|
||||
- fix(core): fix startup stats to use int values for timestamps and durations by
|
||||
@yunaseoul in [#22201](https://github.com/google-gemini/gemini-cli/pull/22201)
|
||||
- fix(core): prevent duplicate tool schemas for instantiated tools by
|
||||
@abhipatel12 in
|
||||
[#22204](https://github.com/google-gemini/gemini-cli/pull/22204)
|
||||
- fix(core): add proxy routing support for remote A2A subagents by @adamfweidman
|
||||
in [#22199](https://github.com/google-gemini/gemini-cli/pull/22199)
|
||||
- fix(core/ide): add Antigravity CLI fallbacks by @apfine in
|
||||
[#22030](https://github.com/google-gemini/gemini-cli/pull/22030)
|
||||
- fix(browser): fix duplicate function declaration error in browser agent by
|
||||
@gsquared94 in
|
||||
[#22207](https://github.com/google-gemini/gemini-cli/pull/22207)
|
||||
- feat(core): implement Stage 1 improvements for webfetch tool by @aishaneeshah
|
||||
in [#21313](https://github.com/google-gemini/gemini-cli/pull/21313)
|
||||
- Changelog for v0.34.0-preview.1 by @gemini-cli-robot in
|
||||
[#22194](https://github.com/google-gemini/gemini-cli/pull/22194)
|
||||
- perf(cli): enable code splitting and deferred UI loading by @sehoon38 in
|
||||
[#22117](https://github.com/google-gemini/gemini-cli/pull/22117)
|
||||
- fix: remove unused img.png from project root by @SandyTao520 in
|
||||
[#22222](https://github.com/google-gemini/gemini-cli/pull/22222)
|
||||
- docs(local model routing): add docs on how to use Gemma for local model
|
||||
routing by @douglas-reid in
|
||||
[#21365](https://github.com/google-gemini/gemini-cli/pull/21365)
|
||||
- feat(a2a): enable native gRPC support and protocol routing by @alisa-alisa in
|
||||
[#21403](https://github.com/google-gemini/gemini-cli/pull/21403)
|
||||
- fix(cli): escape @ symbols on paste to prevent unintended file expansion by
|
||||
@krishdef7 in [#21239](https://github.com/google-gemini/gemini-cli/pull/21239)
|
||||
- feat(core): add trajectoryId to ConversationOffered telemetry by @yunaseoul in
|
||||
[#22214](https://github.com/google-gemini/gemini-cli/pull/22214)
|
||||
- docs: clarify that tools.core is an allowlist for ALL built-in tools by
|
||||
@hobostay in [#18813](https://github.com/google-gemini/gemini-cli/pull/18813)
|
||||
- docs(plan): document hooks with plan mode by @ruomengz in
|
||||
[#22197](https://github.com/google-gemini/gemini-cli/pull/22197)
|
||||
- Changelog for v0.33.1 by @gemini-cli-robot in
|
||||
[#22235](https://github.com/google-gemini/gemini-cli/pull/22235)
|
||||
- build(ci): fix false positive evals trigger on merge commits by @gundermanc in
|
||||
[#22237](https://github.com/google-gemini/gemini-cli/pull/22237)
|
||||
- fix(core): explicitly pass messageBus to policy engine for MCP tool saves by
|
||||
@abhipatel12 in
|
||||
[#22255](https://github.com/google-gemini/gemini-cli/pull/22255)
|
||||
- feat(core): Fully migrate packages/core to AgentLoopContext. by @joshualitt in
|
||||
[#22115](https://github.com/google-gemini/gemini-cli/pull/22115)
|
||||
- feat(core): increase sub-agent turn and time limits by @bdmorgan in
|
||||
[#22196](https://github.com/google-gemini/gemini-cli/pull/22196)
|
||||
- feat(core): instrument file system tools for JIT context discovery by
|
||||
@SandyTao520 in
|
||||
[#22082](https://github.com/google-gemini/gemini-cli/pull/22082)
|
||||
- refactor(ui): extract pure session browser utilities by @abhipatel12 in
|
||||
[#22256](https://github.com/google-gemini/gemini-cli/pull/22256)
|
||||
- fix(plan): Fix AskUser evals by @Adib234 in
|
||||
[#22074](https://github.com/google-gemini/gemini-cli/pull/22074)
|
||||
- fix(settings): prevent j/k navigation keys from intercepting edit buffer input
|
||||
by @student-ankitpandit in
|
||||
[#21865](https://github.com/google-gemini/gemini-cli/pull/21865)
|
||||
- feat(skills): improve async-pr-review workflow and logging by @mattKorwel in
|
||||
[#21790](https://github.com/google-gemini/gemini-cli/pull/21790)
|
||||
- refactor(cli): consolidate getErrorMessage utility to core by @scidomino in
|
||||
[#22190](https://github.com/google-gemini/gemini-cli/pull/22190)
|
||||
- fix(core): show descriptive error messages when saving settings fails by
|
||||
@afarber in [#18095](https://github.com/google-gemini/gemini-cli/pull/18095)
|
||||
- docs(core): add authentication guide for remote subagents by @adamfweidman in
|
||||
[#22178](https://github.com/google-gemini/gemini-cli/pull/22178)
|
||||
- docs: overhaul subagents documentation and add /agents command by @abhipatel12
|
||||
in [#22345](https://github.com/google-gemini/gemini-cli/pull/22345)
|
||||
- refactor(ui): extract SessionBrowser static ui components by @abhipatel12 in
|
||||
[#22348](https://github.com/google-gemini/gemini-cli/pull/22348)
|
||||
- test: add Object.create context regression test and tool confirmation
|
||||
integration test by @gsquared94 in
|
||||
[#22356](https://github.com/google-gemini/gemini-cli/pull/22356)
|
||||
- feat(tracker): return TodoList display for tracker tools by @anj-s in
|
||||
[#22060](https://github.com/google-gemini/gemini-cli/pull/22060)
|
||||
- feat(agent): add allowed domain restrictions for browser agent by
|
||||
@cynthialong0-0 in
|
||||
[#21775](https://github.com/google-gemini/gemini-cli/pull/21775)
|
||||
- chore/release: bump version to 0.35.0-nightly.20260313.bb060d7a9 by
|
||||
@gemini-cli-robot in
|
||||
[#22251](https://github.com/google-gemini/gemini-cli/pull/22251)
|
||||
- Move keychain fallback to keychain service by @chrstnb in
|
||||
[#22332](https://github.com/google-gemini/gemini-cli/pull/22332)
|
||||
- feat(core): integrate SandboxManager to sandbox all process-spawning tools by
|
||||
@galz10 in [#22231](https://github.com/google-gemini/gemini-cli/pull/22231)
|
||||
- fix(cli): support CJK input and full Unicode scalar values in terminal
|
||||
protocols by @scidomino in
|
||||
[#22353](https://github.com/google-gemini/gemini-cli/pull/22353)
|
||||
- Promote stable tests. by @gundermanc in
|
||||
[#22253](https://github.com/google-gemini/gemini-cli/pull/22253)
|
||||
- feat(tracker): add tracker policy by @anj-s in
|
||||
[#22379](https://github.com/google-gemini/gemini-cli/pull/22379)
|
||||
- feat(security): add disableAlwaysAllow setting to disable auto-approvals by
|
||||
@galz10 in [#21941](https://github.com/google-gemini/gemini-cli/pull/21941)
|
||||
- Revert "fix(cli): validate --model argument at startup" by @sehoon38 in
|
||||
[#22378](https://github.com/google-gemini/gemini-cli/pull/22378)
|
||||
- fix(mcp): handle equivalent root resource URLs in OAuth validation by @galz10
|
||||
in [#20231](https://github.com/google-gemini/gemini-cli/pull/20231)
|
||||
- fix(core): use session-specific temp directory for task tracker by @anj-s in
|
||||
[#22382](https://github.com/google-gemini/gemini-cli/pull/22382)
|
||||
- Fix issue where config was undefined. by @gundermanc in
|
||||
[#22397](https://github.com/google-gemini/gemini-cli/pull/22397)
|
||||
- fix(core): deduplicate project memory when JIT context is enabled by
|
||||
@SandyTao520 in
|
||||
[#22234](https://github.com/google-gemini/gemini-cli/pull/22234)
|
||||
- feat(prompts): implement Topic-Action-Summary model for verbosity reduction by
|
||||
@Abhijit-2592 in
|
||||
[#21503](https://github.com/google-gemini/gemini-cli/pull/21503)
|
||||
- fix(core): fix manual deletion of subagent histories by @abhipatel12 in
|
||||
[#22407](https://github.com/google-gemini/gemini-cli/pull/22407)
|
||||
- Add registry var by @kevinjwang1 in
|
||||
[#22224](https://github.com/google-gemini/gemini-cli/pull/22224)
|
||||
- Add ModelDefinitions to ModelConfigService by @kevinjwang1 in
|
||||
[#22302](https://github.com/google-gemini/gemini-cli/pull/22302)
|
||||
- fix(cli): improve command conflict handling for skills by @NTaylorMullen in
|
||||
[#21942](https://github.com/google-gemini/gemini-cli/pull/21942)
|
||||
- fix(core): merge user settings with extension-provided MCP servers by
|
||||
@abhipatel12 in
|
||||
[#22484](https://github.com/google-gemini/gemini-cli/pull/22484)
|
||||
- fix(core): skip discovery for incomplete MCP configs and resolve merge race
|
||||
condition by @abhipatel12 in
|
||||
[#22494](https://github.com/google-gemini/gemini-cli/pull/22494)
|
||||
- fix(automation): harden stale PR closer permissions and maintainer detection
|
||||
by @bdmorgan in
|
||||
[#22558](https://github.com/google-gemini/gemini-cli/pull/22558)
|
||||
- fix(automation): evaluate staleness before checking protected labels by
|
||||
@bdmorgan in [#22561](https://github.com/google-gemini/gemini-cli/pull/22561)
|
||||
- feat(agent): replace the runtime npx for browser agent chrome devtool mcp with
|
||||
pre-built bundle by @cynthialong0-0 in
|
||||
[#22213](https://github.com/google-gemini/gemini-cli/pull/22213)
|
||||
- perf: optimize TrackerService dependency checks by @anj-s in
|
||||
[#22384](https://github.com/google-gemini/gemini-cli/pull/22384)
|
||||
- docs(policy): remove trailing space from commandPrefix examples by @kawasin73
|
||||
in [#22264](https://github.com/google-gemini/gemini-cli/pull/22264)
|
||||
- fix(a2a-server): resolve unsafe assignment lint errors by @ehedlund in
|
||||
[#22661](https://github.com/google-gemini/gemini-cli/pull/22661)
|
||||
- fix: Adjust ToolGroupMessage filtering to hide Confirming and show Canceled
|
||||
tool calls. by @sripasg in
|
||||
[#22230](https://github.com/google-gemini/gemini-cli/pull/22230)
|
||||
- Disallow Object.create() and reflect. by @gundermanc in
|
||||
[#22408](https://github.com/google-gemini/gemini-cli/pull/22408)
|
||||
- Guard pro model usage by @sehoon38 in
|
||||
[#22665](https://github.com/google-gemini/gemini-cli/pull/22665)
|
||||
- refactor(core): Creates AgentSession abstraction for consolidated agent
|
||||
interface. by @mbleigh in
|
||||
[#22270](https://github.com/google-gemini/gemini-cli/pull/22270)
|
||||
- docs(changelog): remove internal commands from release notes by
|
||||
@jackwotherspoon in
|
||||
[#22529](https://github.com/google-gemini/gemini-cli/pull/22529)
|
||||
- feat: enable subagents by @abhipatel12 in
|
||||
[#22386](https://github.com/google-gemini/gemini-cli/pull/22386)
|
||||
- feat(extensions): implement cryptographic integrity verification for extension
|
||||
updates by @ehedlund in
|
||||
[#21772](https://github.com/google-gemini/gemini-cli/pull/21772)
|
||||
- feat(tracker): polish UI sorting and formatting by @anj-s in
|
||||
[#22437](https://github.com/google-gemini/gemini-cli/pull/22437)
|
||||
- Changelog for v0.34.0-preview.2 by @gemini-cli-robot in
|
||||
[#22220](https://github.com/google-gemini/gemini-cli/pull/22220)
|
||||
- fix(core): fix three JIT context bugs in read_file, read_many_files, and
|
||||
memoryDiscovery by @SandyTao520 in
|
||||
[#22679](https://github.com/google-gemini/gemini-cli/pull/22679)
|
||||
- refactor(core): introduce InjectionService with source-aware injection and
|
||||
backend-native background completions by @adamfweidman in
|
||||
[#22544](https://github.com/google-gemini/gemini-cli/pull/22544)
|
||||
- Linux sandbox bubblewrap by @DavidAPierce in
|
||||
[#22680](https://github.com/google-gemini/gemini-cli/pull/22680)
|
||||
- feat(core): increase thought signature retry resilience by @bdmorgan in
|
||||
[#22202](https://github.com/google-gemini/gemini-cli/pull/22202)
|
||||
- feat(core): implement Stage 2 security and consistency improvements for
|
||||
web_fetch by @aishaneeshah in
|
||||
[#22217](https://github.com/google-gemini/gemini-cli/pull/22217)
|
||||
- refactor(core): replace positional execute params with ExecuteOptions bag by
|
||||
@adamfweidman in
|
||||
[#22674](https://github.com/google-gemini/gemini-cli/pull/22674)
|
||||
- feat(config): enable JIT context loading by default by @SandyTao520 in
|
||||
[#22736](https://github.com/google-gemini/gemini-cli/pull/22736)
|
||||
- fix(config): ensure discoveryMaxDirs is passed to global config during
|
||||
initialization by @kevin-ramdass in
|
||||
[#22744](https://github.com/google-gemini/gemini-cli/pull/22744)
|
||||
- fix(plan): allowlist get_internal_docs in Plan Mode by @Adib234 in
|
||||
[#22668](https://github.com/google-gemini/gemini-cli/pull/22668)
|
||||
- Changelog for v0.34.0-preview.3 by @gemini-cli-robot in
|
||||
[#22393](https://github.com/google-gemini/gemini-cli/pull/22393)
|
||||
- feat(core): add foundation for subagent tool isolation by @akh64bit in
|
||||
[#22708](https://github.com/google-gemini/gemini-cli/pull/22708)
|
||||
- fix(core): handle surrogate pairs in truncateString by @sehoon38 in
|
||||
[#22754](https://github.com/google-gemini/gemini-cli/pull/22754)
|
||||
- fix(cli): override j/k navigation in settings dialog to fix search input
|
||||
conflict by @sehoon38 in
|
||||
[#22800](https://github.com/google-gemini/gemini-cli/pull/22800)
|
||||
- feat(plan): add 'All the above' option to multi-select AskUser questions by
|
||||
@Adib234 in [#22365](https://github.com/google-gemini/gemini-cli/pull/22365)
|
||||
- docs: distribute package-specific GEMINI.md context to each package by
|
||||
@SandyTao520 in
|
||||
[#22734](https://github.com/google-gemini/gemini-cli/pull/22734)
|
||||
- fix(cli): clean up stale pasted placeholder metadata after word/line deletions
|
||||
by @Jomak-x in
|
||||
[#20375](https://github.com/google-gemini/gemini-cli/pull/20375)
|
||||
- refactor(core): align JIT memory placement with tiered context model by
|
||||
@SandyTao520 in
|
||||
[#22766](https://github.com/google-gemini/gemini-cli/pull/22766)
|
||||
- Linux sandbox seccomp by @DavidAPierce in
|
||||
[#22815](https://github.com/google-gemini/gemini-cli/pull/22815)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.33.0-preview.15...v0.34.0-preview.4
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.34.0-preview.4...v0.35.0-preview.1
|
||||
|
||||
@@ -460,6 +460,26 @@ Manual deletion also removes all associated artifacts:
|
||||
If you use a [custom plans directory](#custom-plan-directory-and-policies),
|
||||
those files are not automatically deleted and must be managed manually.
|
||||
|
||||
## Non-interactive execution
|
||||
|
||||
When running Gemini CLI in non-interactive environments (such as headless
|
||||
scripts or CI/CD pipelines), Plan Mode optimizes for automated workflows:
|
||||
|
||||
- **Automatic transitions:** The policy engine automatically approves the
|
||||
`enter_plan_mode` and `exit_plan_mode` tools without prompting for user
|
||||
confirmation.
|
||||
- **Automated implementation:** When exiting Plan Mode to execute the plan,
|
||||
Gemini CLI automatically switches to
|
||||
[YOLO mode](../reference/policy-engine.md#approval-modes) instead of the
|
||||
standard Default mode. This allows the CLI to execute the implementation steps
|
||||
automatically without hanging on interactive tool approvals.
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
gemini --approval-mode plan -p "Analyze telemetry and suggest improvements"
|
||||
```
|
||||
|
||||
[`plan.toml`]:
|
||||
https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml
|
||||
[Conductor]: https://github.com/gemini-cli-extensions/conductor
|
||||
|
||||
@@ -90,6 +90,17 @@ If `argsPattern` is specified, the tool's arguments are converted to a stable
|
||||
JSON string, which is then tested against the provided regular expression. If
|
||||
the arguments don't match the pattern, the rule does not apply.
|
||||
|
||||
#### Execution environment
|
||||
|
||||
If `interactive` is specified, the rule will only apply if the CLI's execution
|
||||
environment matches the specified boolean value:
|
||||
|
||||
- `true`: The rule applies only in interactive mode.
|
||||
- `false`: The rule applies only in non-interactive (headless) mode.
|
||||
|
||||
If omitted, the rule applies to both interactive and non-interactive
|
||||
environments.
|
||||
|
||||
### Decisions
|
||||
|
||||
There are three possible decisions a rule can enforce:
|
||||
@@ -286,6 +297,10 @@ deny_message = "Deletion is permanent"
|
||||
|
||||
# (Optional) An array of approval modes where this rule is active.
|
||||
modes = ["autoEdit"]
|
||||
|
||||
# (Optional) A boolean to restrict the rule to interactive (true) or non-interactive (false) environments.
|
||||
# If omitted, the rule applies to both.
|
||||
interactive = true
|
||||
```
|
||||
|
||||
### Using arrays (lists)
|
||||
@@ -360,6 +375,8 @@ priority = 200
|
||||
Specify only the `mcpName` to apply a rule to every tool provided by that
|
||||
server.
|
||||
|
||||
**Note:** This applies to all decision types (`allow`, `deny`, `ask_user`).
|
||||
|
||||
```toml
|
||||
# Denies all tools from the `untrusted-server` MCP
|
||||
[[rule]]
|
||||
|
||||
@@ -58,6 +58,19 @@ your total token usage using the `/stats` command in Gemini CLI.
|
||||
|
||||
## Installation and updates
|
||||
|
||||
### How do I check which version of Gemini CLI I'm currently running?
|
||||
|
||||
You can check your current Gemini CLI version using one of these methods:
|
||||
|
||||
- Run `gemini --version` or `gemini -v` from your terminal
|
||||
- Check the globally installed version using your package manager:
|
||||
- npm: `npm list -g @google/gemini-cli`
|
||||
- pnpm: `pnpm list -g @google/gemini-cli`
|
||||
- yarn: `yarn global list @google/gemini-cli`
|
||||
- bun: `bun pm ls -g @google/gemini-cli`
|
||||
- homebrew: `brew list --versions gemini-cli`
|
||||
- Inside an active Gemini CLI session, use the `/about` command
|
||||
|
||||
### How do I update Gemini CLI to the latest version?
|
||||
|
||||
If you installed it globally via `npm`, update it using the command
|
||||
|
||||
+91
-26
@@ -18,6 +18,18 @@ describe('plan_mode', () => {
|
||||
experimental: { plan: true },
|
||||
};
|
||||
|
||||
const getWriteTargets = (logs: any[]) =>
|
||||
logs
|
||||
.filter((log) => ['write_file', 'replace'].includes(log.toolRequest.name))
|
||||
.map((log) => {
|
||||
try {
|
||||
return JSON.parse(log.toolRequest.args).file_path as string;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: 'should refuse file modification when in plan mode',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
@@ -32,27 +44,23 @@ describe('plan_mode', () => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const writeTargets = toolLogs
|
||||
.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
)
|
||||
.map((log) => {
|
||||
try {
|
||||
return JSON.parse(log.toolRequest.args).file_path;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const exitPlanIndex = toolLogs.findIndex(
|
||||
(log) => log.toolRequest.name === 'exit_plan_mode',
|
||||
);
|
||||
|
||||
const writeTargetsBeforeExitPlan = getWriteTargets(
|
||||
toolLogs.slice(0, exitPlanIndex !== -1 ? exitPlanIndex : undefined),
|
||||
);
|
||||
|
||||
expect(
|
||||
writeTargets,
|
||||
writeTargetsBeforeExitPlan,
|
||||
'Should not attempt to modify README.md in plan mode',
|
||||
).not.toContain('README.md');
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/plan mode|read-only|cannot modify|refuse|exiting/i],
|
||||
testName: `${TEST_PREFIX}should refuse file modification`,
|
||||
testName: `${TEST_PREFIX}should refuse file modification in plan mode`,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -69,24 +77,20 @@ describe('plan_mode', () => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const writeTargets = toolLogs
|
||||
.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
)
|
||||
.map((log) => {
|
||||
try {
|
||||
return JSON.parse(log.toolRequest.args).file_path;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const exitPlanIndex = toolLogs.findIndex(
|
||||
(log) => log.toolRequest.name === 'exit_plan_mode',
|
||||
);
|
||||
|
||||
const writeTargetsBeforeExit = getWriteTargets(
|
||||
toolLogs.slice(0, exitPlanIndex !== -1 ? exitPlanIndex : undefined),
|
||||
);
|
||||
|
||||
// It should NOT write to the docs folder or any other repo path
|
||||
const hasRepoWrite = writeTargets.some(
|
||||
const hasRepoWriteBeforeExit = writeTargetsBeforeExit.some(
|
||||
(path) => path && !path.includes('/plans/'),
|
||||
);
|
||||
expect(
|
||||
hasRepoWrite,
|
||||
hasRepoWriteBeforeExit,
|
||||
'Should not attempt to create files in the repository while in plan mode',
|
||||
).toBe(false);
|
||||
|
||||
@@ -166,4 +170,65 @@ describe('plan_mode', () => {
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should create a plan in plan mode and implement it for a refactoring task',
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
files: {
|
||||
'src/mathUtils.ts':
|
||||
'export const sum = (a: number, b: number) => a + b;\nexport const multiply = (a: number, b: number) => a * b;',
|
||||
'src/main.ts':
|
||||
'import { sum } from "./mathUtils";\nconsole.log(sum(1, 2));',
|
||||
},
|
||||
prompt:
|
||||
'I want to refactor our math utilities. Move the `sum` function from `src/mathUtils.ts` to a new file `src/basicMath.ts` and update `src/main.ts` to use the new file. Please create a detailed implementation plan first, then execute it.',
|
||||
assert: async (rig, result) => {
|
||||
const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode');
|
||||
expect(
|
||||
enterPlanCalled,
|
||||
'Expected enter_plan_mode tool to be called',
|
||||
).toBe(true);
|
||||
|
||||
const exitPlanCalled = await rig.waitForToolCall('exit_plan_mode');
|
||||
expect(exitPlanCalled, 'Expected exit_plan_mode tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
// Check if plan was written
|
||||
const planWrite = toolLogs.find(
|
||||
(log) =>
|
||||
log.toolRequest.name === 'write_file' &&
|
||||
log.toolRequest.args.includes('/plans/'),
|
||||
);
|
||||
expect(
|
||||
planWrite,
|
||||
'Expected a plan file to be written in the plans directory',
|
||||
).toBeDefined();
|
||||
|
||||
// Check for implementation files
|
||||
const newFileWrite = toolLogs.find(
|
||||
(log) =>
|
||||
log.toolRequest.name === 'write_file' &&
|
||||
log.toolRequest.args.includes('src/basicMath.ts'),
|
||||
);
|
||||
expect(
|
||||
newFileWrite,
|
||||
'Expected src/basicMath.ts to be created',
|
||||
).toBeDefined();
|
||||
|
||||
const mainUpdate = toolLogs.find(
|
||||
(log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name) &&
|
||||
log.toolRequest.args.includes('src/main.ts'),
|
||||
);
|
||||
expect(mainUpdate, 'Expected src/main.ts to be updated').toBeDefined();
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -894,6 +894,9 @@ describe('Session', () => {
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
status: 'completed',
|
||||
title: 'Test Tool',
|
||||
locations: [],
|
||||
kind: 'read',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1306,6 +1309,18 @@ describe('Session', () => {
|
||||
expect(path.resolve).toHaveBeenCalled();
|
||||
expect(fs.stat).toHaveBeenCalled();
|
||||
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
status: 'completed',
|
||||
title: 'Read files',
|
||||
locations: [],
|
||||
kind: 'read',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify ReadManyFilesTool was used (implicitly by checking if sendMessageStream was called with resolved content)
|
||||
// Since we mocked ReadManyFilesTool to return specific content, we can check the args passed to sendMessageStream
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
|
||||
@@ -1321,6 +1336,65 @@ describe('Session', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle @path resolution error', async () => {
|
||||
(path.resolve as unknown as Mock).mockReturnValue('/tmp/error.txt');
|
||||
(fs.stat as unknown as Mock).mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
});
|
||||
(isWithinRoot as unknown as Mock).mockReturnValue(true);
|
||||
|
||||
const MockReadManyFilesTool = ReadManyFilesTool as unknown as Mock;
|
||||
MockReadManyFilesTool.mockImplementationOnce(() => ({
|
||||
name: 'read_many_files',
|
||||
kind: 'read',
|
||||
build: vi.fn().mockReturnValue({
|
||||
getDescription: () => 'Read files',
|
||||
toolLocations: () => [],
|
||||
execute: vi.fn().mockRejectedValue(new Error('File read failed')),
|
||||
}),
|
||||
}));
|
||||
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||
|
||||
await expect(
|
||||
session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [
|
||||
{ type: 'text', text: 'Read' },
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file://error.txt',
|
||||
mimeType: 'text/plain',
|
||||
name: 'error.txt',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow('File read failed');
|
||||
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
status: 'failed',
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
content: expect.objectContaining({
|
||||
text: expect.stringMatching(/File read failed/),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
kind: 'read',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle cancellation during prompt', async () => {
|
||||
let streamController: ReadableStreamDefaultController<unknown>;
|
||||
const stream = new ReadableStream({
|
||||
@@ -1434,6 +1508,7 @@ describe('Session', () => {
|
||||
content: expect.objectContaining({ text: 'Tool failed' }),
|
||||
}),
|
||||
]),
|
||||
kind: 'read',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -966,7 +966,10 @@ export class Session {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: callId,
|
||||
status: 'completed',
|
||||
title: invocation.getDescription(),
|
||||
content: content ? [content] : [],
|
||||
locations: invocation.toolLocations(),
|
||||
kind: toAcpToolKind(tool.kind),
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
@@ -1030,6 +1033,7 @@ export class Session {
|
||||
content: [
|
||||
{ type: 'content', content: { type: 'text', text: error.message } },
|
||||
],
|
||||
kind: toAcpToolKind(tool.kind),
|
||||
});
|
||||
|
||||
this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [
|
||||
@@ -1324,7 +1328,10 @@ export class Session {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: callId,
|
||||
status: 'completed',
|
||||
title: invocation.getDescription(),
|
||||
content: content ? [content] : [],
|
||||
locations: invocation.toolLocations(),
|
||||
kind: toAcpToolKind(readManyFilesTool.kind),
|
||||
});
|
||||
if (Array.isArray(result.llmContent)) {
|
||||
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
|
||||
@@ -1368,6 +1375,7 @@ export class Session {
|
||||
},
|
||||
},
|
||||
],
|
||||
kind: toAcpToolKind(readManyFilesTool.kind),
|
||||
});
|
||||
|
||||
throw error;
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
type HookDefinition,
|
||||
type HookEventName,
|
||||
type ResolvedExtensionSetting,
|
||||
type TrajectoryProvider,
|
||||
coreEvents,
|
||||
applyAdminAllowlist,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
@@ -957,6 +958,23 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
);
|
||||
}
|
||||
|
||||
let trajectoryProviderModule: TrajectoryProvider | undefined;
|
||||
if (config.trajectoryProvider) {
|
||||
try {
|
||||
const expectedPath = path.resolve(
|
||||
effectiveExtensionPath,
|
||||
config.trajectoryProvider,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
trajectoryProviderModule = (await import(expectedPath))
|
||||
.default as TrajectoryProvider;
|
||||
} catch (e) {
|
||||
debugLogger.warn(
|
||||
`Failed to import trajectoryProvider at ${config.trajectoryProvider} for extension ${config.name}: ${getErrorMessage(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: config.name,
|
||||
version: config.version,
|
||||
@@ -980,6 +998,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
rules,
|
||||
checkers,
|
||||
plan: config.plan,
|
||||
trajectoryProviderModule,
|
||||
};
|
||||
} catch (e) {
|
||||
const extName = path.basename(extensionDir);
|
||||
|
||||
@@ -46,6 +46,11 @@ export interface ExtensionConfig {
|
||||
* Used to migrate an extension to a new repository source.
|
||||
*/
|
||||
migratedTo?: string;
|
||||
/**
|
||||
* Path to a module that implements the TrajectoryProvider interface.
|
||||
* Used for importing binary chat histories like Jetski Teleportation.
|
||||
*/
|
||||
trajectoryProvider?: string;
|
||||
}
|
||||
|
||||
export interface ExtensionUpdateInfo {
|
||||
|
||||
@@ -19,12 +19,16 @@ import { performInitialAuth } from './auth.js';
|
||||
import { validateTheme } from './theme.js';
|
||||
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';
|
||||
|
||||
import { pathToFileURL } from 'node:url';
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
|
||||
export interface InitializationResult {
|
||||
authError: string | null;
|
||||
accountSuspensionInfo: AccountSuspensionInfo | null;
|
||||
themeError: string | null;
|
||||
shouldOpenAuthDialog: boolean;
|
||||
geminiMdFileCount: number;
|
||||
recentExternalSession?: { prefix: string; id: string; displayName?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,11 +64,34 @@ export async function initializeApp(
|
||||
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
|
||||
}
|
||||
|
||||
// Check for recent external sessions to prompt for resume
|
||||
let recentExternalSession:
|
||||
| { prefix: string; id: string; displayName?: string }
|
||||
| undefined;
|
||||
if (config.getEnableExtensionReloading() !== false) {
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
|
||||
const extensionLoader = (config as any)._extensionLoader;
|
||||
if (extensionLoader) {
|
||||
const workspaceUri = pathToFileURL(process.cwd()).toString();
|
||||
const recent =
|
||||
await extensionLoader.getRecentExternalSession(workspaceUri);
|
||||
if (recent) {
|
||||
recentExternalSession = {
|
||||
prefix: recent.prefix,
|
||||
id: recent.id,
|
||||
displayName: recent.displayName,
|
||||
};
|
||||
}
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
|
||||
}
|
||||
|
||||
return {
|
||||
authError,
|
||||
accountSuspensionInfo,
|
||||
themeError,
|
||||
shouldOpenAuthDialog,
|
||||
geminiMdFileCount: config.getGeminiMdFileCount(),
|
||||
recentExternalSession,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,12 +58,6 @@ const mockSchedulerSchedule = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const { LegacyAgentSession } = await import(
|
||||
'../../core/src/agent/legacy-agent-session.js'
|
||||
);
|
||||
const { geminiPartsToContentParts } = await import(
|
||||
'../../core/src/agent/content-utils.js'
|
||||
);
|
||||
|
||||
class MockChatRecordingService {
|
||||
initialize = vi.fn();
|
||||
@@ -83,8 +77,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
uiTelemetryService: {
|
||||
getMetrics: vi.fn(),
|
||||
},
|
||||
LegacyAgentSession,
|
||||
geminiPartsToContentParts,
|
||||
coreEvents: mockCoreEvents,
|
||||
createWorkingStdio: vi.fn(() => ({
|
||||
stdout: process.stdout,
|
||||
@@ -116,8 +108,6 @@ describe('runNonInteractive', () => {
|
||||
sendMessageStream: Mock;
|
||||
resumeChat: Mock;
|
||||
getChatRecordingService: Mock;
|
||||
getChat: Mock;
|
||||
getCurrentSequenceModel: Mock;
|
||||
};
|
||||
const MOCK_SESSION_METRICS: SessionMetrics = {
|
||||
models: {},
|
||||
@@ -173,8 +163,6 @@ describe('runNonInteractive', () => {
|
||||
recordMessageTokens: vi.fn(),
|
||||
recordToolCalls: vi.fn(),
|
||||
})),
|
||||
getChat: vi.fn(() => ({ recordCompletedToolCalls: vi.fn() })),
|
||||
getCurrentSequenceModel: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
|
||||
mockConfig = {
|
||||
@@ -271,6 +259,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
false,
|
||||
'Test input',
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Hello World\n');
|
||||
// Note: Telemetry shutdown is now handled in runExitCleanup() in cleanup.ts
|
||||
@@ -387,6 +378,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Tool response' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
});
|
||||
@@ -544,6 +538,9 @@ describe('runNonInteractive', () => {
|
||||
],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-3',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
|
||||
});
|
||||
@@ -561,7 +558,7 @@ describe('runNonInteractive', () => {
|
||||
input: 'Initial fail',
|
||||
prompt_id: 'prompt-id-4',
|
||||
}),
|
||||
).rejects.toThrow('API connection failed');
|
||||
).rejects.toThrow(apiError);
|
||||
});
|
||||
|
||||
it('should not exit if a tool is not found, and should send error back to model', async () => {
|
||||
@@ -683,6 +680,9 @@ describe('runNonInteractive', () => {
|
||||
processedParts,
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-7',
|
||||
undefined,
|
||||
false,
|
||||
rawInput,
|
||||
);
|
||||
|
||||
// 6. Assert the final output is correct
|
||||
@@ -716,6 +716,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
false,
|
||||
'Test input',
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
@@ -846,6 +849,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Empty response test' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-empty',
|
||||
undefined,
|
||||
false,
|
||||
'Empty response test',
|
||||
);
|
||||
|
||||
// This should output JSON with empty response but include stats
|
||||
@@ -935,7 +941,7 @@ describe('runNonInteractive', () => {
|
||||
{
|
||||
session_id: 'test-session-id',
|
||||
error: {
|
||||
type: 'Error',
|
||||
type: 'FatalInputError',
|
||||
message: 'Invalid command syntax provided',
|
||||
code: 42,
|
||||
},
|
||||
@@ -980,6 +986,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Prompt from command' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
undefined,
|
||||
false,
|
||||
'/testcommand',
|
||||
);
|
||||
|
||||
expect(getWrittenOutput()).toBe('Response from command\n');
|
||||
@@ -1023,6 +1032,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Slash command output' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
undefined,
|
||||
false,
|
||||
'/help',
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Response to slash command\n');
|
||||
handleSlashCommandSpy.mockRestore();
|
||||
@@ -1197,6 +1209,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: '/unknowncommand' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-unknown',
|
||||
undefined,
|
||||
false,
|
||||
'/unknowncommand',
|
||||
);
|
||||
|
||||
expect(getWrittenOutput()).toBe('Response to unknown\n');
|
||||
@@ -1761,13 +1776,15 @@ describe('runNonInteractive', () => {
|
||||
throw new Error('Recording failed');
|
||||
}),
|
||||
};
|
||||
// @ts-expect-error - Mocking internal structure
|
||||
mockGeminiClient.getChat = vi.fn().mockReturnValue(mockChat);
|
||||
// @ts-expect-error - Mocking internal structure
|
||||
mockGeminiClient.getCurrentSequenceModel = vi
|
||||
.fn()
|
||||
.mockReturnValue('model-1');
|
||||
|
||||
// Mock debugLogger.error
|
||||
const { debugLogger } = await import('../../core/src/utils/debugLogger.js');
|
||||
const { debugLogger } = await import('@google/gemini-cli-core');
|
||||
const debugLoggerErrorSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
@@ -1982,6 +1999,7 @@ describe('runNonInteractive', () => {
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'Agent execution stopped: Stopped by hook\n',
|
||||
);
|
||||
// Should exit without calling sendMessageStream again
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -2012,9 +2030,9 @@ describe('runNonInteractive', () => {
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'[WARNING] Agent execution blocked: Blocked by hook\n',
|
||||
);
|
||||
// Stream continues after blocked event — content should be output
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
// sendMessageStream is called once, recursion is internal to it and transparent to the caller
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2155,40 +2173,6 @@ describe('runNonInteractive', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit warning event for loop_detected custom event in streaming JSON mode', async () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
const streamEvents: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.LoopDetected } as ServerGeminiStreamEvent,
|
||||
{ type: GeminiEventType.Content, value: 'Continuing after loop' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(streamEvents),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Loop test explicit',
|
||||
prompt_id: 'prompt-id-loop-explicit',
|
||||
});
|
||||
|
||||
const output = getWrittenOutput();
|
||||
// The STREAM_JSON output should contain an error event with warning severity
|
||||
expect(output).toContain('"type":"error"');
|
||||
expect(output).toContain('"severity":"warning"');
|
||||
expect(output).toContain('Loop detected');
|
||||
});
|
||||
|
||||
it('should report cancelled tool calls as success in stream-json mode (legacy parity)', async () => {
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
|
||||
@@ -6,15 +6,15 @@
|
||||
|
||||
import type {
|
||||
Config,
|
||||
ToolCallRequestInfo,
|
||||
ResumedSessionData,
|
||||
UserFeedbackPayload,
|
||||
AgentEvent,
|
||||
ContentPart,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import {
|
||||
convertSessionToClientHistory,
|
||||
GeminiEventType,
|
||||
FatalInputError,
|
||||
promptIdContext,
|
||||
OutputFormat,
|
||||
@@ -22,17 +22,17 @@ import {
|
||||
StreamJsonFormatter,
|
||||
JsonStreamEventType,
|
||||
uiTelemetryService,
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
createWorkingStdio,
|
||||
recordToolCallInteractions,
|
||||
ToolErrorType,
|
||||
Scheduler,
|
||||
ROOT_SCHEDULER_ID,
|
||||
LegacyAgentSession,
|
||||
ToolErrorType,
|
||||
geminiPartsToContentParts,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import type { Part } from '@google/genai';
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import readline from 'node:readline';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
@@ -150,6 +150,8 @@ export async function runNonInteractive({
|
||||
}, 200);
|
||||
|
||||
abortController.abort();
|
||||
// Note: Don't exit here - let the abort flow through the system
|
||||
// and trigger handleCancellationError() which will exit with proper code
|
||||
}
|
||||
};
|
||||
|
||||
@@ -244,6 +246,9 @@ export async function runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
);
|
||||
// If a slash command is found and returns a prompt, use it.
|
||||
// Otherwise, slashCommandResult falls through to the default prompt
|
||||
// handling.
|
||||
if (slashCommandResult) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
query = slashCommandResult as Part[];
|
||||
@@ -261,6 +266,8 @@ export async function runNonInteractive({
|
||||
escapePastedAtSymbols: false,
|
||||
});
|
||||
if (error || !processedQuery) {
|
||||
// An error occurred during @include processing (e.g., file not found).
|
||||
// The error message is already logged by handleAtCommand.
|
||||
throw new FatalInputError(
|
||||
error || 'Exiting due to an error processing the @ command.',
|
||||
);
|
||||
@@ -279,239 +286,235 @@ export async function runNonInteractive({
|
||||
});
|
||||
}
|
||||
|
||||
// Create LegacyAgentSession — owns the agentic loop
|
||||
const session = new LegacyAgentSession({
|
||||
client: geminiClient,
|
||||
scheduler,
|
||||
config,
|
||||
promptId: prompt_id,
|
||||
});
|
||||
let currentMessages: Content[] = [{ role: 'user', parts: query }];
|
||||
|
||||
// Wire Ctrl+C to session abort
|
||||
abortController.signal.addEventListener('abort', () => {
|
||||
void session.abort();
|
||||
});
|
||||
|
||||
// Start the agentic loop (runs in background)
|
||||
await session.send({
|
||||
message: geminiPartsToContentParts(query),
|
||||
});
|
||||
|
||||
const getFirstText = (parts?: ContentPart[]): string | undefined => {
|
||||
const part = parts?.[0];
|
||||
return part?.type === 'text' ? part.text : undefined;
|
||||
};
|
||||
|
||||
const emitFinalSuccessResult = (): void => {
|
||||
if (streamFormatter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const durationMs = Date.now() - startTime;
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
const formatter = new JsonFormatter();
|
||||
const stats = uiTelemetryService.getMetrics();
|
||||
textOutput.write(
|
||||
formatter.format(config.getSessionId(), responseText, stats),
|
||||
);
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline();
|
||||
let turnCount = 0;
|
||||
while (true) {
|
||||
turnCount++;
|
||||
if (
|
||||
config.getMaxSessionTurns() >= 0 &&
|
||||
turnCount > config.getMaxSessionTurns()
|
||||
) {
|
||||
handleMaxTurnsExceededError(config);
|
||||
}
|
||||
};
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
|
||||
const reconstructFatalError = (event: AgentEvent<'error'>): Error => {
|
||||
const errToThrow = new Error(event.message);
|
||||
const errorMeta = event._meta;
|
||||
if (errorMeta?.['exitCode'] !== undefined) {
|
||||
Object.defineProperty(errToThrow, 'exitCode', {
|
||||
value: errorMeta['exitCode'],
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
if (errorMeta?.['errorName'] !== undefined) {
|
||||
Object.defineProperty(errToThrow, 'name', {
|
||||
value: errorMeta['errorName'],
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
if (errorMeta?.['code'] !== undefined) {
|
||||
Object.defineProperty(errToThrow, 'code', {
|
||||
value: errorMeta['code'],
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
return errToThrow;
|
||||
};
|
||||
const responseStream = geminiClient.sendMessageStream(
|
||||
currentMessages[0]?.parts || [],
|
||||
abortController.signal,
|
||||
prompt_id,
|
||||
undefined,
|
||||
false,
|
||||
turnCount === 1 ? input : undefined,
|
||||
);
|
||||
|
||||
// Consume AgentEvents for output formatting
|
||||
let responseText = '';
|
||||
let streamEnded = false;
|
||||
for await (const event of session.stream()) {
|
||||
if (streamEnded) break;
|
||||
switch (event.type) {
|
||||
case 'message': {
|
||||
if (event.role === 'agent') {
|
||||
for (const part of event.content) {
|
||||
if (part.type === 'text') {
|
||||
const isRaw =
|
||||
config.getRawOutput() || config.getAcceptRawOutputRisk();
|
||||
const output = isRaw ? part.text : stripAnsi(part.text);
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.MESSAGE,
|
||||
timestamp: new Date().toISOString(),
|
||||
role: 'assistant',
|
||||
content: output,
|
||||
delta: true,
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
responseText += output;
|
||||
} else {
|
||||
if (part.text) {
|
||||
textOutput.write(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
let responseText = '';
|
||||
for await (const event of responseStream) {
|
||||
if (abortController.signal.aborted) {
|
||||
handleCancellationError(config);
|
||||
}
|
||||
|
||||
if (event.type === GeminiEventType.Content) {
|
||||
const isRaw =
|
||||
config.getRawOutput() || config.getAcceptRawOutputRisk();
|
||||
const output = isRaw ? event.value : stripAnsi(event.value);
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.MESSAGE,
|
||||
timestamp: new Date().toISOString(),
|
||||
role: 'assistant',
|
||||
content: output,
|
||||
delta: true,
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
responseText += output;
|
||||
} else {
|
||||
if (event.value) {
|
||||
textOutput.write(output);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool_request': {
|
||||
} else if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.TOOL_USE,
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_name: event.name,
|
||||
tool_id: event.requestId,
|
||||
parameters: event.args,
|
||||
tool_name: event.value.name,
|
||||
tool_id: event.value.callId,
|
||||
parameters: event.value.args,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool_response': {
|
||||
textOutput.ensureTrailingNewline();
|
||||
if (streamFormatter) {
|
||||
const displayText = getFirstText(event.displayContent);
|
||||
const errorMsg = getFirstText(event.content) ?? 'Tool error';
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.TOOL_RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_id: event.requestId,
|
||||
status: event.isError ? 'error' : 'success',
|
||||
output: displayText,
|
||||
error: event.isError
|
||||
? {
|
||||
type:
|
||||
typeof event.data?.['errorType'] === 'string'
|
||||
? event.data['errorType']
|
||||
: 'TOOL_EXECUTION_ERROR',
|
||||
message: errorMsg,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
if (event.isError) {
|
||||
const displayText = getFirstText(event.displayContent);
|
||||
const errorMsg = getFirstText(event.content) ?? 'Tool error';
|
||||
|
||||
if (event.data?.['errorType'] === ToolErrorType.STOP_EXECUTION) {
|
||||
const stopMessage = `Agent execution stopped: ${errorMsg}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`${stopMessage}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
handleToolError(
|
||||
event.name,
|
||||
new Error(errorMsg),
|
||||
config,
|
||||
typeof event.data?.['errorType'] === 'string'
|
||||
? event.data['errorType']
|
||||
: undefined,
|
||||
displayText,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
if (event.fatal) {
|
||||
throw reconstructFatalError(event);
|
||||
}
|
||||
|
||||
const errorCode = event._meta?.['code'];
|
||||
|
||||
if (errorCode === 'MAX_TURNS_EXCEEDED') {
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'error',
|
||||
message: event.message,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (errorCode === 'AGENT_EXECUTION_BLOCKED') {
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`[WARNING] ${event.message}\n`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const severity =
|
||||
event.status === 'RESOURCE_EXHAUSTED' ? 'error' : 'warning';
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`[WARNING] ${event.message}\n`);
|
||||
}
|
||||
toolCallRequests.push(event.value);
|
||||
} else if (event.type === GeminiEventType.LoopDetected) {
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity,
|
||||
message: event.message,
|
||||
severity: 'warning',
|
||||
message: 'Loop detected, stopping execution',
|
||||
});
|
||||
}
|
||||
break;
|
||||
} else if (event.type === GeminiEventType.MaxSessionTurns) {
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'error',
|
||||
message: 'Maximum session turns exceeded',
|
||||
});
|
||||
}
|
||||
} else if (event.type === GeminiEventType.Error) {
|
||||
throw event.value.error;
|
||||
} else if (event.type === GeminiEventType.AgentExecutionStopped) {
|
||||
const stopMessage = `Agent execution stopped: ${event.value.systemMessage?.trim() || event.value.reason}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`${stopMessage}\n`);
|
||||
}
|
||||
// Emit final result event for streaming JSON if needed
|
||||
if (streamFormatter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const durationMs = Date.now() - startTime;
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
stats: streamFormatter.convertToStreamStats(
|
||||
metrics,
|
||||
durationMs,
|
||||
),
|
||||
});
|
||||
}
|
||||
return;
|
||||
} else if (event.type === GeminiEventType.AgentExecutionBlocked) {
|
||||
const blockMessage = `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`[WARNING] ${blockMessage}\n`);
|
||||
}
|
||||
}
|
||||
case 'stream_end': {
|
||||
if (event.reason === 'aborted') {
|
||||
handleCancellationError(config);
|
||||
} else if (event.reason === 'max_turns') {
|
||||
handleMaxTurnsExceededError(config);
|
||||
}
|
||||
|
||||
if (toolCallRequests.length > 0) {
|
||||
textOutput.ensureTrailingNewline();
|
||||
const completedToolCalls = await scheduler.schedule(
|
||||
toolCallRequests,
|
||||
abortController.signal,
|
||||
);
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
for (const completedToolCall of completedToolCalls) {
|
||||
const toolResponse = completedToolCall.response;
|
||||
const requestInfo = completedToolCall.request;
|
||||
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.TOOL_RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_id: requestInfo.callId,
|
||||
status:
|
||||
completedToolCall.status === 'error' ? 'error' : 'success',
|
||||
output:
|
||||
typeof toolResponse.resultDisplay === 'string'
|
||||
? toolResponse.resultDisplay
|
||||
: undefined,
|
||||
error: toolResponse.error
|
||||
? {
|
||||
type: toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
|
||||
message: toolResponse.error.message,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const stopMessage =
|
||||
typeof event.data?.['message'] === 'string'
|
||||
? event.data['message']
|
||||
: '';
|
||||
if (stopMessage && config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`Agent execution stopped: ${stopMessage}\n`);
|
||||
if (toolResponse.error) {
|
||||
handleToolError(
|
||||
requestInfo.name,
|
||||
toolResponse.error,
|
||||
config,
|
||||
toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
|
||||
typeof toolResponse.resultDisplay === 'string'
|
||||
? toolResponse.resultDisplay
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
emitFinalSuccessResult();
|
||||
streamEnded = true;
|
||||
break;
|
||||
}
|
||||
case 'custom': {
|
||||
if (event.kind === 'loop_detected') {
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'warning',
|
||||
message: 'Loop detected, stopping execution',
|
||||
});
|
||||
}
|
||||
if (toolResponse.responseParts) {
|
||||
toolResponseParts.push(...toolResponse.responseParts);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
|
||||
// Record tool calls with full metadata before sending responses to Gemini
|
||||
try {
|
||||
const currentModel =
|
||||
geminiClient.getCurrentSequenceModel() ?? config.getModel();
|
||||
geminiClient
|
||||
.getChat()
|
||||
.recordCompletedToolCalls(currentModel, completedToolCalls);
|
||||
|
||||
await recordToolCallInteractions(config, completedToolCalls);
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Error recording completed tool call information: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if any tool requested to stop execution immediately
|
||||
const stopExecutionTool = completedToolCalls.find(
|
||||
(tc) => tc.response.errorType === ToolErrorType.STOP_EXECUTION,
|
||||
);
|
||||
|
||||
if (stopExecutionTool && stopExecutionTool.response.error) {
|
||||
const stopMessage = `Agent execution stopped: ${stopExecutionTool.response.error.message}`;
|
||||
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`${stopMessage}\n`);
|
||||
}
|
||||
|
||||
// Emit final result event for streaming JSON
|
||||
if (streamFormatter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const durationMs = Date.now() - startTime;
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
stats: streamFormatter.convertToStreamStats(
|
||||
metrics,
|
||||
durationMs,
|
||||
),
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
const formatter = new JsonFormatter();
|
||||
const stats = uiTelemetryService.getMetrics();
|
||||
textOutput.write(
|
||||
formatter.format(config.getSessionId(), responseText, stats),
|
||||
);
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
||||
} else {
|
||||
// Emit final result event for streaming JSON
|
||||
if (streamFormatter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const durationMs = Date.now() - startTime;
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
const formatter = new JsonFormatter();
|
||||
const stats = uiTelemetryService.getMetrics();
|
||||
textOutput.write(
|
||||
formatter.format(config.getSessionId(), responseText, stats),
|
||||
);
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -469,6 +469,19 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
generateSummary(config).catch((e) => {
|
||||
debugLogger.warn('Background summary generation failed:', e);
|
||||
});
|
||||
|
||||
// Show handoff prompt for recent external sessions
|
||||
if (initializationResult.recentExternalSession) {
|
||||
const { prefix, id, displayName } =
|
||||
initializationResult.recentExternalSession;
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `🛸 You have a recent session in ${displayName || 'an external tool'}. Type "/resume ${prefix}${id}" to bring it into the terminal.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
})();
|
||||
registerCleanup(async () => {
|
||||
// Turn off mouse scroll.
|
||||
|
||||
@@ -8,10 +8,14 @@ import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { agentsCommand } from './agentsCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { Storage } from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { enableAgent, disableAgent } from '../../utils/agentSettings.js';
|
||||
import { renderAgentActionFeedback } from '../../utils/agentUtils.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
|
||||
vi.mock('../../utils/agentSettings.js', () => ({
|
||||
enableAgent: vi.fn(),
|
||||
@@ -105,40 +109,34 @@ describe('agentsCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should reload the agent registry when reload subcommand is called', async () => {
|
||||
it('should reload the agent registry when refresh subcommand is called', async () => {
|
||||
const reloadSpy = vi.fn().mockResolvedValue(undefined);
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||
reload: reloadSpy,
|
||||
});
|
||||
|
||||
const reloadCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'reload',
|
||||
const refreshCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'refresh',
|
||||
);
|
||||
expect(reloadCommand).toBeDefined();
|
||||
expect(refreshCommand).toBeDefined();
|
||||
|
||||
const result = await reloadCommand!.action!(mockContext, '');
|
||||
const result = await refreshCommand!.action!(mockContext, '');
|
||||
|
||||
expect(reloadSpy).toHaveBeenCalled();
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Reloading agent registry...',
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Agents reloaded successfully',
|
||||
content: 'Agents refreshed successfully.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show an error if agent registry is not available during reload', async () => {
|
||||
it('should show an error if agent registry is not available during refresh', async () => {
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue(undefined);
|
||||
|
||||
const reloadCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'reload',
|
||||
const refreshCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'refresh',
|
||||
);
|
||||
const result = await reloadCommand!.action!(mockContext, '');
|
||||
const result = await refreshCommand!.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
@@ -462,4 +460,56 @@ describe('agentsCommand', () => {
|
||||
expect(completions).toEqual(['agent1', 'agent2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('import sub-command', () => {
|
||||
it('should import an agent with correct tool mapping', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(`---
|
||||
name: test-claude-agent
|
||||
tools: Read, Glob, Grep, Bash
|
||||
model: sonnet
|
||||
---
|
||||
System prompt content`);
|
||||
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.spyOn(Storage, 'getUserAgentsDir').mockReturnValue('/mock/agents');
|
||||
|
||||
const importCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'import',
|
||||
);
|
||||
expect(importCommand).toBeDefined();
|
||||
|
||||
const result = await importCommand!.action!(
|
||||
mockContext,
|
||||
'/path/to/claude.md',
|
||||
);
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledWith('/path/to/claude.md', 'utf-8');
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/test-claude-agent\.md$/),
|
||||
expect.stringContaining('tools:\n - read_file\n - run_command\n'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining(
|
||||
"Successfully imported agent 'test-claude-agent'",
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error if no file path provided', async () => {
|
||||
const importCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'import',
|
||||
);
|
||||
const result = await importCommand!.action!(mockContext, ' ');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Usage: /agents import <file-path>',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as fsPromises from 'node:fs/promises';
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import type {
|
||||
CommandContext,
|
||||
SlashCommand,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
decodeTagName,
|
||||
type MessageActionReturn,
|
||||
INITIAL_HISTORY_LENGTH,
|
||||
type ConversationRecord,
|
||||
} from '@google/gemini-cli-core';
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
@@ -174,8 +176,119 @@ const resumeCheckpointCommand: SlashCommand = {
|
||||
|
||||
const { logger, config } = context.services;
|
||||
await logger.initialize();
|
||||
const checkpoint = await logger.loadCheckpoint(tag);
|
||||
const conversation = checkpoint.history;
|
||||
|
||||
let conversation: readonly Content[] = [];
|
||||
let authType: string | undefined;
|
||||
|
||||
const loadExternalTrajectory = async (
|
||||
prefix: string,
|
||||
id: string,
|
||||
): Promise<Content[] | null> => {
|
||||
let record: ConversationRecord | null = null;
|
||||
if (config && config.getEnableExtensionReloading() !== false) {
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
|
||||
const extensions = (config as any)._extensionLoader?.getExtensions
|
||||
? (config as any)._extensionLoader.getExtensions()
|
||||
: [];
|
||||
for (const extension of extensions) {
|
||||
if (
|
||||
extension.trajectoryProviderModule &&
|
||||
extension.trajectoryProviderModule.prefix === prefix
|
||||
) {
|
||||
try {
|
||||
record = await extension.trajectoryProviderModule.loadSession(id);
|
||||
if (record) break;
|
||||
} catch (_err) {
|
||||
// Ignore failure
|
||||
}
|
||||
}
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
|
||||
}
|
||||
if (!record) return null;
|
||||
|
||||
const conv: Content[] = [];
|
||||
// Add a dummy system message so slice(INITIAL_HISTORY_LENGTH) works correctly
|
||||
conv.push({ role: 'user', parts: [{ text: '' }] });
|
||||
|
||||
for (const m of record.messages) {
|
||||
if (m.type === 'user') {
|
||||
const parts = Array.isArray(m.content)
|
||||
? m.content.map((c: string | Part) =>
|
||||
typeof c === 'string' ? { text: c } : { text: c.text || '' },
|
||||
)
|
||||
: [{ text: '' }];
|
||||
conv.push({ role: 'user', parts });
|
||||
} else if (m.type === 'gemini') {
|
||||
// 1. Model turn: Text + Function Calls
|
||||
const modelParts: Part[] = [];
|
||||
if (Array.isArray(m.content)) {
|
||||
m.content.forEach((c: string | Part) => {
|
||||
modelParts.push(
|
||||
typeof c === 'string' ? { text: c } : { text: c.text || '' },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (m.toolCalls) {
|
||||
for (const tc of m.toolCalls) {
|
||||
modelParts.push({
|
||||
functionCall: {
|
||||
name: tc.name,
|
||||
args: tc.args,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
conv.push({ role: 'model', parts: modelParts });
|
||||
|
||||
// 2. User turn: Function Responses (if any)
|
||||
if (
|
||||
m.toolCalls &&
|
||||
m.toolCalls.some((tc: { result?: unknown }) => tc.result)
|
||||
) {
|
||||
const responseParts: Part[] = [];
|
||||
for (const tc of m.toolCalls) {
|
||||
if (tc.result) {
|
||||
responseParts.push({
|
||||
functionResponse: {
|
||||
name: tc.name,
|
||||
response: { result: tc.result },
|
||||
id: tc.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if (responseParts.length > 0) {
|
||||
conv.push({ role: 'user', parts: responseParts });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return conv;
|
||||
};
|
||||
|
||||
// Check if tag format is prefix:id
|
||||
const prefixMatch = tag.match(/^([a-zA-Z0-9_]+):(.*)$/);
|
||||
if (prefixMatch) {
|
||||
const prefix = prefixMatch[1] + ':';
|
||||
const id = prefixMatch[2];
|
||||
const externalConv = await loadExternalTrajectory(prefix, id);
|
||||
if (!externalConv) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `No external session found with prefix ${prefix} and id: ${id}`,
|
||||
};
|
||||
}
|
||||
conversation = externalConv;
|
||||
} else {
|
||||
const checkpoint = await logger.loadCheckpoint(tag);
|
||||
if (checkpoint.history.length > 0) {
|
||||
conversation = checkpoint.history;
|
||||
authType = checkpoint.authType;
|
||||
}
|
||||
}
|
||||
|
||||
if (conversation.length === 0) {
|
||||
return {
|
||||
@@ -186,15 +299,11 @@ const resumeCheckpointCommand: SlashCommand = {
|
||||
}
|
||||
|
||||
const currentAuthType = config?.getContentGeneratorConfig()?.authType;
|
||||
if (
|
||||
checkpoint.authType &&
|
||||
currentAuthType &&
|
||||
checkpoint.authType !== currentAuthType
|
||||
) {
|
||||
if (authType && currentAuthType && authType !== currentAuthType) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Cannot resume chat. It was saved with a different authentication method (${checkpoint.authType}) than the current one (${currentAuthType}).`,
|
||||
content: `Cannot resume chat. It was saved with a different authentication method (${authType}) than the current one (${currentAuthType}).`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,11 +315,34 @@ const resumeCheckpointCommand: SlashCommand = {
|
||||
const uiHistory: HistoryItemWithoutId[] = [];
|
||||
|
||||
for (const item of conversation.slice(INITIAL_HISTORY_LENGTH)) {
|
||||
const text =
|
||||
item.parts
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const parts = item.parts as Array<{
|
||||
text?: string;
|
||||
functionCall?: { name: string };
|
||||
functionResponse?: { name: string };
|
||||
}>;
|
||||
let text =
|
||||
parts
|
||||
?.filter((m) => !!m.text)
|
||||
.map((m) => m.text)
|
||||
.join('') || '';
|
||||
|
||||
const toolCalls = parts?.filter((p) => !!p.functionCall);
|
||||
if (toolCalls?.length) {
|
||||
const calls = toolCalls
|
||||
.map((tc) => `[Tool Call: ${tc.functionCall?.name}]`)
|
||||
.join('\n');
|
||||
text = text ? `${text}\n${calls}` : calls;
|
||||
}
|
||||
|
||||
const toolResponses = parts?.filter((p) => !!p.functionResponse);
|
||||
if (toolResponses?.length) {
|
||||
const responses = toolResponses
|
||||
.map((tr) => `[Tool Result: ${tr.functionResponse?.name}]`)
|
||||
.join('\n');
|
||||
text = text ? `${text}\n${responses}` : responses;
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,11 @@ export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
|
||||
? Math.min(availableHeightLimit, maxLines)
|
||||
: (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT);
|
||||
|
||||
const lastLines = disableTruncation ? data : data.slice(-numLinesRetained);
|
||||
const lastLines = disableTruncation
|
||||
? data
|
||||
: numLinesRetained === 0
|
||||
? []
|
||||
: data.slice(-numLinesRetained);
|
||||
return (
|
||||
<Box flexDirection="column" width={width} flexShrink={0} overflow="hidden">
|
||||
{lastLines.map((line: AnsiLine, lineIndex: number) => (
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { Banner } from './Banner.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
@@ -12,22 +12,23 @@ describe('Banner', () => {
|
||||
it.each([
|
||||
['warning mode', true, 'Warning Message'],
|
||||
['info mode', false, 'Info Message'],
|
||||
['multi-line warning', true, 'Title Line\\nBody Line 1\\nBody Line 2'],
|
||||
])('renders in %s', async (_, isWarning, text) => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
const renderResult = renderWithProviders(
|
||||
<Banner bannerText={text} isWarning={isWarning} width={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
await renderResult.waitUntilReady();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
renderResult.unmount();
|
||||
});
|
||||
|
||||
it('handles newlines in text', async () => {
|
||||
const text = 'Line 1\\nLine 2';
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
const renderResult = renderWithProviders(
|
||||
<Banner bannerText={text} isWarning={false} width={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
await renderResult.waitUntilReady();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
renderResult.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,20 +14,21 @@ export function getFormattedBannerContent(
|
||||
isWarning: boolean,
|
||||
subsequentLineColor: string,
|
||||
): ReactNode {
|
||||
if (isWarning) {
|
||||
return (
|
||||
<Text color={theme.status.warning}>{rawText.replace(/\\n/g, '\n')}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const text = rawText.replace(/\\n/g, '\n');
|
||||
const lines = text.split('\n');
|
||||
|
||||
return lines.map((line, index) => {
|
||||
if (index === 0) {
|
||||
if (isWarning) {
|
||||
return (
|
||||
<Text key={index} bold color={theme.status.warning}>
|
||||
{line}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ThemedGradient key={index}>
|
||||
<Text>{line}</Text>
|
||||
<Text bold>{line}</Text>
|
||||
</ThemedGradient>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export const MainContent = () => {
|
||||
pendingHistoryItems,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
availableTerminalHeight,
|
||||
cleanUiDetailsVisible,
|
||||
} = uiState;
|
||||
const showHeaderDetails = cleanUiDetailsVisible;
|
||||
@@ -141,7 +142,7 @@ export const MainContent = () => {
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight ? staticAreaMaxItemHeight : undefined
|
||||
uiState.constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
@@ -160,7 +161,7 @@ export const MainContent = () => {
|
||||
[
|
||||
pendingHistoryItems,
|
||||
uiState.constrainHeight,
|
||||
staticAreaMaxItemHeight,
|
||||
availableTerminalHeight,
|
||||
mainAreaWidth,
|
||||
showConfirmationQueue,
|
||||
confirmingTool,
|
||||
|
||||
@@ -12,12 +12,16 @@ import { Colors } from '../colors.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import path from 'node:path';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import type { SessionInfo } from '../../utils/sessionUtils.js';
|
||||
import {
|
||||
formatRelativeTime,
|
||||
getSessionFiles,
|
||||
} from '../../utils/sessionUtils.js';
|
||||
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
|
||||
import { TabHeader, type Tab } from './shared/TabHeader.js';
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */
|
||||
|
||||
/**
|
||||
* Props for the main SessionBrowser component.
|
||||
@@ -41,6 +45,8 @@ export interface SessionBrowserState {
|
||||
// Data state
|
||||
/** All loaded sessions */
|
||||
sessions: SessionInfo[];
|
||||
/** Extension tabs */
|
||||
extensionTabs: Array<{ name: string; sessions: SessionInfo[] }>;
|
||||
/** Sessions after filtering and sorting */
|
||||
filteredAndSortedSessions: SessionInfo[];
|
||||
|
||||
@@ -55,6 +61,8 @@ export interface SessionBrowserState {
|
||||
scrollOffset: number;
|
||||
/** Terminal width for layout calculations */
|
||||
terminalWidth: number;
|
||||
/** Current active tab (0: CLI, 1+: External) */
|
||||
activeTab: number;
|
||||
|
||||
// Search state
|
||||
/** Current search query string */
|
||||
@@ -83,6 +91,10 @@ export interface SessionBrowserState {
|
||||
// State setters
|
||||
/** Update sessions array */
|
||||
setSessions: React.Dispatch<React.SetStateAction<SessionInfo[]>>;
|
||||
/** Update extensionTabs array */
|
||||
setExtensionTabs: React.Dispatch<
|
||||
React.SetStateAction<Array<{ name: string; sessions: SessionInfo[] }>>
|
||||
>;
|
||||
/** Update loading state */
|
||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
/** Update error state */
|
||||
@@ -91,6 +103,8 @@ export interface SessionBrowserState {
|
||||
setActiveIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
/** Update scroll offset */
|
||||
setScrollOffset: React.Dispatch<React.SetStateAction<number>>;
|
||||
/** Update active tab */
|
||||
setActiveTab: (index: number) => void;
|
||||
/** Update search query */
|
||||
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
|
||||
/** Update search mode state */
|
||||
@@ -352,6 +366,9 @@ export const useSessionBrowserState = (
|
||||
): SessionBrowserState => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>(initialSessions);
|
||||
const [extensionTabs, setExtensionTabs] = useState<
|
||||
Array<{ name: string; sessions: SessionInfo[] }>
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(initialLoading);
|
||||
const [error, setError] = useState<string | null>(initialError);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
@@ -365,10 +382,20 @@ export const useSessionBrowserState = (
|
||||
const [hasLoadedFullContent, setHasLoadedFullContent] = useState(false);
|
||||
const loadingFullContentRef = useRef(false);
|
||||
|
||||
const [activeTab, setActiveTabInternal] = useState(0);
|
||||
|
||||
const setActiveTab = useCallback((index: number) => {
|
||||
setActiveTabInternal(index);
|
||||
setActiveIndex(0);
|
||||
setScrollOffset(0);
|
||||
}, []);
|
||||
|
||||
const filteredAndSortedSessions = useMemo(() => {
|
||||
const filtered = filterSessions(sessions, searchQuery);
|
||||
const currentTabSessions =
|
||||
activeTab === 0 ? sessions : extensionTabs[activeTab - 1]?.sessions || [];
|
||||
const filtered = filterSessions(currentTabSessions, searchQuery);
|
||||
return sortSessions(filtered, sortOrder, sortReverse);
|
||||
}, [sessions, searchQuery, sortOrder, sortReverse]);
|
||||
}, [sessions, extensionTabs, activeTab, searchQuery, sortOrder, sortReverse]);
|
||||
|
||||
// Reset full content flag when search is cleared
|
||||
useEffect(() => {
|
||||
@@ -386,6 +413,8 @@ export const useSessionBrowserState = (
|
||||
const state: SessionBrowserState = {
|
||||
sessions,
|
||||
setSessions,
|
||||
extensionTabs,
|
||||
setExtensionTabs,
|
||||
loading,
|
||||
setLoading,
|
||||
error,
|
||||
@@ -394,6 +423,8 @@ export const useSessionBrowserState = (
|
||||
setActiveIndex,
|
||||
scrollOffset,
|
||||
setScrollOffset,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
isSearchMode,
|
||||
@@ -415,12 +446,43 @@ export const useSessionBrowserState = (
|
||||
return state;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts an external session info to CLI SessionInfo format.
|
||||
*/
|
||||
type ExternalSessionInfo = {
|
||||
id: string;
|
||||
mtime: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
messageCount?: number;
|
||||
prefix?: string;
|
||||
};
|
||||
|
||||
function convertExternalToSessionInfo(
|
||||
ext: ExternalSessionInfo,
|
||||
index: number,
|
||||
): SessionInfo {
|
||||
return {
|
||||
id: ext.prefix ? `${ext.prefix}${ext.id}` : ext.id,
|
||||
file: ext.id,
|
||||
fileName: ext.id + '.ext',
|
||||
startTime: ext.mtime,
|
||||
lastUpdated: ext.mtime,
|
||||
messageCount: ext.messageCount || 0,
|
||||
displayName: ext.displayName || ext.name || 'External Session',
|
||||
firstUserMessage: ext.displayName || ext.name || '',
|
||||
isCurrentSession: false,
|
||||
index,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to load sessions on mount.
|
||||
*/
|
||||
const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
||||
const {
|
||||
setSessions,
|
||||
setExtensionTabs,
|
||||
setLoading,
|
||||
setError,
|
||||
isSearchMode,
|
||||
@@ -432,11 +494,58 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
||||
const loadSessions = async () => {
|
||||
try {
|
||||
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
|
||||
const sessionData = await getSessionFiles(
|
||||
chatsDir,
|
||||
config.getSessionId(),
|
||||
);
|
||||
const workspaceUri = pathToFileURL(process.cwd()).toString();
|
||||
|
||||
const externalTabs: Array<{ name: string; sessions: SessionInfo[] }> =
|
||||
[];
|
||||
if (config.getEnableExtensionReloading() !== false) {
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
|
||||
const extensions = (config as any)._extensionLoader?.getExtensions
|
||||
? (config as any)._extensionLoader.getExtensions()
|
||||
: [];
|
||||
for (const extension of extensions) {
|
||||
if (extension.trajectoryProviderModule) {
|
||||
try {
|
||||
const sessions =
|
||||
await extension.trajectoryProviderModule.listSessions(
|
||||
workspaceUri,
|
||||
);
|
||||
const normalizedExt = sessions
|
||||
.map((ext: any) => ({
|
||||
...ext,
|
||||
prefix: extension.trajectoryProviderModule.prefix,
|
||||
}))
|
||||
.sort(
|
||||
(a: any, b: any) =>
|
||||
new Date(a.mtime).getTime() - new Date(b.mtime).getTime(),
|
||||
)
|
||||
.map((ext: any, i: number) =>
|
||||
convertExternalToSessionInfo(ext, i + 1),
|
||||
);
|
||||
|
||||
if (normalizedExt.length > 0) {
|
||||
externalTabs.push({
|
||||
name:
|
||||
extension.trajectoryProviderModule.displayName ||
|
||||
extension.name,
|
||||
sessions: normalizedExt,
|
||||
});
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore loader errors
|
||||
}
|
||||
}
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
|
||||
}
|
||||
|
||||
const [sessionData] = await Promise.all([
|
||||
getSessionFiles(chatsDir, config.getSessionId()),
|
||||
]);
|
||||
|
||||
setSessions(sessionData);
|
||||
setExtensionTabs(externalTabs);
|
||||
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError(
|
||||
@@ -448,7 +557,7 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
loadSessions();
|
||||
}, [config, setSessions, setLoading, setError]);
|
||||
}, [config, setSessions, setExtensionTabs, setLoading, setError]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadFullContent = async () => {
|
||||
@@ -610,6 +719,9 @@ export const useSessionBrowserInput = (
|
||||
}
|
||||
// Delete session control.
|
||||
else if (key.sequence === 'x' || key.sequence === 'X') {
|
||||
// Only allow deleting CLI sessions for now
|
||||
if (state.activeTab !== 0) return true;
|
||||
|
||||
const selectedSession =
|
||||
state.filteredAndSortedSessions[state.activeIndex];
|
||||
if (selectedSession && !selectedSession.isCurrentSession) {
|
||||
@@ -684,6 +796,14 @@ export function SessionBrowserView({
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element {
|
||||
const tabs: Tab[] = [
|
||||
{ key: 'cli', header: 'Gemini CLI' },
|
||||
...state.extensionTabs.map((ext, i) => ({
|
||||
key: `ext-${i}`,
|
||||
header: ext.name,
|
||||
})),
|
||||
];
|
||||
|
||||
if (state.loading) {
|
||||
return <SessionBrowserLoading />;
|
||||
}
|
||||
@@ -692,11 +812,20 @@ export function SessionBrowserView({
|
||||
return <SessionBrowserError state={state} />;
|
||||
}
|
||||
|
||||
if (state.sessions.length === 0) {
|
||||
if (state.sessions.length === 0 && state.extensionTabs.length === 0) {
|
||||
return <SessionBrowserEmpty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box marginBottom={1}>
|
||||
<TabHeader
|
||||
tabs={tabs}
|
||||
currentIndex={state.activeTab}
|
||||
showStatusIcons={false}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<SessionListHeader state={state} />
|
||||
|
||||
{state.isSearchMode && <SearchModeDisplay state={state} />}
|
||||
@@ -721,6 +850,14 @@ export function SessionBrowser({
|
||||
useLoadSessions(config, state);
|
||||
const moveSelection = useMoveSelection(state);
|
||||
const cycleSortOrder = useCycleSortOrder(state);
|
||||
|
||||
useTabbedNavigation({
|
||||
tabCount: state.extensionTabs.length + 1,
|
||||
isActive: !state.isSearchMode,
|
||||
wrapAround: true,
|
||||
onTabChange: (index) => state.setActiveTab(index),
|
||||
});
|
||||
|
||||
useSessionBrowserInput(
|
||||
state,
|
||||
moveSelection,
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="88" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#333333" textLength="720" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">L</text>
|
||||
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">i</text>
|
||||
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">n</text>
|
||||
<text x="45" y="19" fill="#9974b4" textLength="9" lengthAdjust="spacingAndGlyphs">e</text>
|
||||
<text x="63" y="19" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">1</text>
|
||||
<text x="711" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="36" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">Line 2</text>
|
||||
<text x="711" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="53" fill="#333333" textLength="720" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
+23
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#333333" textLength="720" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">I</text>
|
||||
<text x="27" y="19" fill="#5390e0" textLength="9" lengthAdjust="spacingAndGlyphs">n</text>
|
||||
<text x="36" y="19" fill="#5f8bdb" textLength="9" lengthAdjust="spacingAndGlyphs">f</text>
|
||||
<text x="45" y="19" fill="#6c85d7" textLength="9" lengthAdjust="spacingAndGlyphs">o</text>
|
||||
<text x="63" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">M</text>
|
||||
<text x="72" y="19" fill="#8f77c1" textLength="9" lengthAdjust="spacingAndGlyphs">e</text>
|
||||
<text x="81" y="19" fill="#9974b4" textLength="9" lengthAdjust="spacingAndGlyphs">s</text>
|
||||
<text x="90" y="19" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">s</text>
|
||||
<text x="99" y="19" fill="#ae6d99" textLength="9" lengthAdjust="spacingAndGlyphs">a</text>
|
||||
<text x="108" y="19" fill="#b96a8c" textLength="9" lengthAdjust="spacingAndGlyphs">g</text>
|
||||
<text x="117" y="19" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">e</text>
|
||||
<text x="711" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#333333" textLength="720" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
+19
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="105" viewBox="0 0 920 105">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="105" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#ffffaf" textLength="720" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="19" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#ffffaf" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Title Line</text>
|
||||
<text x="711" y="19" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="36" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs">Body Line 1</text>
|
||||
<text x="711" y="36" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="53" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="53" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs">Body Line 2</text>
|
||||
<text x="711" y="53" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="70" fill="#ffffaf" textLength="720" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
+13
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#ffffaf" textLength="720" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="19" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#ffffaf" textLength="135" lengthAdjust="spacingAndGlyphs" font-weight="bold">Warning Message</text>
|
||||
<text x="711" y="19" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#ffffaf" textLength="720" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -4,20 +4,25 @@ exports[`Banner > handles newlines in text 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Line 1 │
|
||||
│ Line 2 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`Banner > renders in info mode 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Info Message │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`Banner > renders in multi-line warning 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Title Line │
|
||||
│ Body Line 1 │
|
||||
│ Body Line 2 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`Banner > renders in warning mode 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Warning Message │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -6,11 +6,12 @@ AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 9 │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
│ Line 14 █ │
|
||||
│ Line 15 █ │
|
||||
│ Line 16 █ │
|
||||
│ Line 17 █ │
|
||||
@@ -27,11 +28,12 @@ AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 9 │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
│ Line 14 █ │
|
||||
│ Line 15 █ │
|
||||
│ Line 16 █ │
|
||||
│ Line 17 █ │
|
||||
@@ -47,7 +49,9 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ ... first 11 lines hidden (Ctrl+O to show) ... │
|
||||
│ ... first 9 lines hidden (Ctrl+O to show) ... │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
|
||||
@@ -199,7 +199,7 @@ describe('<ShellToolMessage />', () => {
|
||||
[
|
||||
'uses full availableTerminalHeight when focused in alternate buffer mode',
|
||||
100,
|
||||
98, // 100 - 2
|
||||
98,
|
||||
true,
|
||||
false,
|
||||
],
|
||||
|
||||
@@ -4,16 +4,18 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||
import type {
|
||||
SerializableConfirmationDetails,
|
||||
ToolCallConfirmationDetails,
|
||||
Config,
|
||||
import {
|
||||
type SerializableConfirmationDetails,
|
||||
type ToolCallConfirmationDetails,
|
||||
type Config,
|
||||
ToolConfirmationOutcome,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
import { act } from 'react';
|
||||
|
||||
vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -646,4 +648,63 @@ describe('ToolConfirmationMessage', () => {
|
||||
expect(output).not.toContain('Invocation Arguments:');
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('ESCAPE key behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should call confirm(Cancel) asynchronously via useEffect when ESC is pressed', async () => {
|
||||
const mockConfirm = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(useToolActions).mockReturnValue({
|
||||
confirm: mockConfirm,
|
||||
cancel: vi.fn(),
|
||||
isDiffingEnabled: false,
|
||||
});
|
||||
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'info',
|
||||
title: 'Confirm Web Fetch',
|
||||
prompt: 'https://example.com',
|
||||
urls: ['https://example.com'],
|
||||
};
|
||||
|
||||
const { stdin, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
getPreferredEditor={vi.fn()}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
stdin.write('\x1b');
|
||||
|
||||
// To assert that the confirmation happens asynchronously (via useEffect) rather than
|
||||
// synchronously (directly inside the keystroke handler), we must run our assertion
|
||||
// *inside* the act() block.
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Now that the act() block has returned, React flushes the useEffect, calling handleConfirm.
|
||||
expect(mockConfirm).toHaveBeenCalledWith(
|
||||
'test-call-id',
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
undefined,
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import { useEffect, useMemo, useCallback, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
|
||||
@@ -79,6 +79,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
callId,
|
||||
expanded: false,
|
||||
});
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const isMcpToolDetailsExpanded =
|
||||
mcpDetailsExpansionState.callId === callId
|
||||
? mcpDetailsExpansionState.expanded
|
||||
@@ -183,7 +184,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
handleConfirm(ToolConfirmationOutcome.Cancel);
|
||||
setIsCancelling(true);
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
@@ -196,6 +197,20 @@ export const ToolConfirmationMessage: React.FC<
|
||||
{ isActive: isFocused, priority: true },
|
||||
);
|
||||
|
||||
// TODO(#23009): Remove this hack once we migrate to the new renderer.
|
||||
// Why useEffect is used here instead of calling handleConfirm directly:
|
||||
// There is a race condition where calling handleConfirm immediately upon
|
||||
// keypress removes the tool UI component while the UI is in an expanded state.
|
||||
// This simultaneously triggers setConstrainHeight, causing render two footers.
|
||||
// By bridging the cancel action through state (isCancelling) and this useEffect,
|
||||
// we delay handleConfirm until the next render cycle, ensuring setConstrainHeight
|
||||
// resolves properly first.
|
||||
useEffect(() => {
|
||||
if (isCancelling) {
|
||||
handleConfirm(ToolConfirmationOutcome.Cancel);
|
||||
}
|
||||
}, [isCancelling, handleConfirm]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(item: ToolConfirmationOutcome) => handleConfirm(item),
|
||||
[handleConfirm],
|
||||
|
||||
@@ -46,7 +46,7 @@ export function SlicingMaxSizedBox<T>({
|
||||
text = '...' + text.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
|
||||
}
|
||||
}
|
||||
if (maxLines) {
|
||||
if (maxLines !== undefined) {
|
||||
const hasTrailingNewline = text.endsWith('\n');
|
||||
const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
|
||||
const lines = contentText.split('\n');
|
||||
@@ -71,7 +71,7 @@ export function SlicingMaxSizedBox<T>({
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(data) && !isAlternateBuffer && maxLines) {
|
||||
if (Array.isArray(data) && !isAlternateBuffer && maxLines !== undefined) {
|
||||
if (data.length > maxLines) {
|
||||
// We will have a label from MaxSizedBox. Reserve space for it.
|
||||
const targetLines = Math.max(1, maxLines - 1);
|
||||
|
||||
@@ -75,7 +75,6 @@ export const INFORMATIVE_TIPS = [
|
||||
'Set the character threshold for truncating tool outputs (/settings)…',
|
||||
'Set the number of lines to keep when truncating outputs (/settings)…',
|
||||
'Enable policy-based tool confirmation via message bus (/settings)…',
|
||||
'Enable write_todos_list tool to generate task lists (/settings)…',
|
||||
'Enable experimental subagents for task delegation (/settings)…',
|
||||
'Enable extension management features (settings.json)…',
|
||||
'Enable extension reloading within the CLI session (settings.json)…',
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type SessionInfo,
|
||||
} from '../../utils/sessionUtils.js';
|
||||
import type { Part } from '@google/genai';
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
|
||||
export { convertSessionToHistoryFormats };
|
||||
|
||||
@@ -51,20 +52,54 @@ export const useSessionBrowser = (
|
||||
handleResumeSession: useCallback(
|
||||
async (session: SessionInfo) => {
|
||||
try {
|
||||
const chatsDir = path.join(
|
||||
config.storage.getProjectTempDir(),
|
||||
'chats',
|
||||
);
|
||||
let conversation: ConversationRecord;
|
||||
let filePath: string;
|
||||
|
||||
const fileName = session.fileName;
|
||||
if (session.fileName.endsWith('.ext')) {
|
||||
// External session
|
||||
let externalConv: ConversationRecord | null = null;
|
||||
if (config.getEnableExtensionReloading() !== false) {
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
|
||||
const extensions = (config as any)._extensionLoader?.getExtensions
|
||||
? (config as any)._extensionLoader.getExtensions()
|
||||
: [];
|
||||
for (const extension of extensions) {
|
||||
if (extension.trajectoryProviderModule) {
|
||||
const prefix =
|
||||
extension.trajectoryProviderModule.prefix || '';
|
||||
if (session.id.startsWith(prefix)) {
|
||||
const originalId = prefix
|
||||
? session.id.slice(prefix.length)
|
||||
: session.id;
|
||||
externalConv =
|
||||
await extension.trajectoryProviderModule.loadSession(
|
||||
originalId,
|
||||
);
|
||||
if (externalConv) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
|
||||
}
|
||||
|
||||
const originalFilePath = path.join(chatsDir, fileName);
|
||||
if (!externalConv) {
|
||||
throw new Error(`Could not load external session ${session.id}`);
|
||||
}
|
||||
conversation = externalConv;
|
||||
filePath = session.id + '.ext';
|
||||
} else {
|
||||
// Regular CLI session
|
||||
const chatsDir = path.join(
|
||||
config.storage.getProjectTempDir(),
|
||||
'chats',
|
||||
);
|
||||
const fileName = session.fileName;
|
||||
filePath = path.join(chatsDir, fileName);
|
||||
|
||||
// Load up the conversation.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const conversation: ConversationRecord = JSON.parse(
|
||||
await fs.readFile(originalFilePath, 'utf8'),
|
||||
);
|
||||
// Load up the conversation.
|
||||
|
||||
conversation = JSON.parse(await fs.readFile(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
// Use the old session's ID to continue it.
|
||||
const existingSessionId = conversation.sessionId;
|
||||
@@ -73,7 +108,7 @@ export const useSessionBrowser = (
|
||||
|
||||
const resumedSessionData = {
|
||||
conversation,
|
||||
filePath: originalFilePath,
|
||||
filePath,
|
||||
};
|
||||
|
||||
// We've loaded it; tell the UI about it.
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
calculateToolContentMaxLines,
|
||||
calculateShellMaxLines,
|
||||
SHELL_CONTENT_OVERHEAD,
|
||||
} from './toolLayoutUtils.js';
|
||||
import { CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
import {
|
||||
ACTIVE_SHELL_MAX_LINES,
|
||||
COMPLETED_SHELL_MAX_LINES,
|
||||
} from '../constants.js';
|
||||
|
||||
describe('toolLayoutUtils', () => {
|
||||
describe('calculateToolContentMaxLines', () => {
|
||||
interface CalculateToolContentMaxLinesTestCase {
|
||||
desc: string;
|
||||
options: Parameters<typeof calculateToolContentMaxLines>[0];
|
||||
expected: number | undefined;
|
||||
}
|
||||
|
||||
const testCases: CalculateToolContentMaxLinesTestCase[] = [
|
||||
{
|
||||
desc: 'returns undefined if availableTerminalHeight is undefined',
|
||||
options: {
|
||||
availableTerminalHeight: undefined,
|
||||
isAlternateBuffer: false,
|
||||
},
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
desc: 'returns maxLinesLimit if maxLinesLimit applies but availableTerminalHeight is undefined',
|
||||
options: {
|
||||
availableTerminalHeight: undefined,
|
||||
isAlternateBuffer: false,
|
||||
maxLinesLimit: 10,
|
||||
},
|
||||
expected: 10,
|
||||
},
|
||||
{
|
||||
desc: 'returns available space directly in constrained terminal (Standard mode)',
|
||||
options: {
|
||||
availableTerminalHeight: 2,
|
||||
isAlternateBuffer: false,
|
||||
},
|
||||
expected: 3,
|
||||
},
|
||||
{
|
||||
desc: 'returns available space directly in constrained terminal (ASB mode)',
|
||||
options: {
|
||||
availableTerminalHeight: 4,
|
||||
isAlternateBuffer: true,
|
||||
},
|
||||
expected: 3,
|
||||
},
|
||||
{
|
||||
desc: 'returns remaining space if sufficient space exists (Standard mode)',
|
||||
options: {
|
||||
availableTerminalHeight: 20,
|
||||
isAlternateBuffer: false,
|
||||
},
|
||||
expected: 17,
|
||||
},
|
||||
{
|
||||
desc: 'returns remaining space if sufficient space exists (ASB mode)',
|
||||
options: {
|
||||
availableTerminalHeight: 20,
|
||||
isAlternateBuffer: true,
|
||||
},
|
||||
expected: 13,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)('$desc', ({ options, expected }) => {
|
||||
const result = calculateToolContentMaxLines(options);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateShellMaxLines', () => {
|
||||
interface CalculateShellMaxLinesTestCase {
|
||||
desc: string;
|
||||
options: Parameters<typeof calculateShellMaxLines>[0];
|
||||
expected: number | undefined;
|
||||
}
|
||||
|
||||
const testCases: CalculateShellMaxLinesTestCase[] = [
|
||||
{
|
||||
desc: 'returns undefined when not constrained and is expandable',
|
||||
options: {
|
||||
status: CoreToolCallStatus.Executing,
|
||||
isAlternateBuffer: false,
|
||||
isThisShellFocused: false,
|
||||
availableTerminalHeight: 20,
|
||||
constrainHeight: false,
|
||||
isExpandable: true,
|
||||
},
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
desc: 'returns ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD for ASB mode when availableTerminalHeight is undefined',
|
||||
options: {
|
||||
status: CoreToolCallStatus.Executing,
|
||||
isAlternateBuffer: true,
|
||||
isThisShellFocused: false,
|
||||
availableTerminalHeight: undefined,
|
||||
constrainHeight: true,
|
||||
isExpandable: false,
|
||||
},
|
||||
expected: ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD,
|
||||
},
|
||||
{
|
||||
desc: 'returns undefined for Standard mode when availableTerminalHeight is undefined',
|
||||
options: {
|
||||
status: CoreToolCallStatus.Executing,
|
||||
isAlternateBuffer: false,
|
||||
isThisShellFocused: false,
|
||||
availableTerminalHeight: undefined,
|
||||
constrainHeight: true,
|
||||
isExpandable: false,
|
||||
},
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
desc: 'handles small availableTerminalHeight gracefully without overflow in Standard mode',
|
||||
options: {
|
||||
status: CoreToolCallStatus.Executing,
|
||||
isAlternateBuffer: false,
|
||||
isThisShellFocused: false,
|
||||
availableTerminalHeight: 2,
|
||||
constrainHeight: true,
|
||||
isExpandable: false,
|
||||
},
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
desc: 'handles small availableTerminalHeight gracefully without overflow in ASB mode',
|
||||
options: {
|
||||
status: CoreToolCallStatus.Executing,
|
||||
isAlternateBuffer: true,
|
||||
isThisShellFocused: false,
|
||||
availableTerminalHeight: 6,
|
||||
constrainHeight: true,
|
||||
isExpandable: false,
|
||||
},
|
||||
expected: 4,
|
||||
},
|
||||
{
|
||||
desc: 'handles negative availableTerminalHeight gracefully',
|
||||
options: {
|
||||
status: CoreToolCallStatus.Executing,
|
||||
isAlternateBuffer: false,
|
||||
isThisShellFocused: false,
|
||||
availableTerminalHeight: -5,
|
||||
constrainHeight: true,
|
||||
isExpandable: false,
|
||||
},
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
desc: 'returns maxLinesBasedOnHeight for focused ASB shells',
|
||||
options: {
|
||||
status: CoreToolCallStatus.Executing,
|
||||
isAlternateBuffer: true,
|
||||
isThisShellFocused: true,
|
||||
availableTerminalHeight: 30,
|
||||
constrainHeight: false,
|
||||
isExpandable: false,
|
||||
},
|
||||
expected: 28,
|
||||
},
|
||||
{
|
||||
desc: 'falls back to COMPLETED_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD for completed shells if space allows',
|
||||
options: {
|
||||
status: CoreToolCallStatus.Success,
|
||||
isAlternateBuffer: false,
|
||||
isThisShellFocused: false,
|
||||
availableTerminalHeight: 100,
|
||||
constrainHeight: true,
|
||||
isExpandable: false,
|
||||
},
|
||||
expected: COMPLETED_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD,
|
||||
},
|
||||
{
|
||||
desc: 'falls back to ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD for executing shells if space allows',
|
||||
options: {
|
||||
status: CoreToolCallStatus.Executing,
|
||||
isAlternateBuffer: false,
|
||||
isThisShellFocused: false,
|
||||
availableTerminalHeight: 100,
|
||||
constrainHeight: true,
|
||||
isExpandable: false,
|
||||
},
|
||||
expected: ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)('$desc', ({ options, expected }) => {
|
||||
const result = calculateShellMaxLines(options);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -46,12 +46,13 @@ export function calculateToolContentMaxLines(options: {
|
||||
? TOOL_RESULT_ASB_RESERVED_LINE_COUNT
|
||||
: TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT;
|
||||
|
||||
let contentHeight = availableTerminalHeight
|
||||
? Math.max(
|
||||
availableTerminalHeight - TOOL_RESULT_STATIC_HEIGHT - reservedLines,
|
||||
TOOL_RESULT_MIN_LINES_SHOWN + 1,
|
||||
)
|
||||
: undefined;
|
||||
let contentHeight =
|
||||
availableTerminalHeight !== undefined
|
||||
? Math.max(
|
||||
availableTerminalHeight - TOOL_RESULT_STATIC_HEIGHT - reservedLines,
|
||||
TOOL_RESULT_MIN_LINES_SHOWN + 1,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (maxLinesLimit !== undefined) {
|
||||
contentHeight =
|
||||
@@ -100,7 +101,10 @@ export function calculateShellMaxLines(options: {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2);
|
||||
const maxLinesBasedOnHeight = Math.max(
|
||||
1,
|
||||
availableTerminalHeight - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
|
||||
);
|
||||
|
||||
// 3. Handle ASB mode focus expansion.
|
||||
// We allow a focused shell in ASB mode to take up the full available height,
|
||||
|
||||
@@ -202,7 +202,12 @@ describe('handleAutoUpdate', () => {
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([PackageManager.NPX, PackageManager.PNPX, PackageManager.BUNX])(
|
||||
it.each([
|
||||
PackageManager.NPX,
|
||||
PackageManager.PNPX,
|
||||
PackageManager.BUNX,
|
||||
PackageManager.BINARY,
|
||||
])(
|
||||
'should suppress update notifications when running via %s',
|
||||
(packageManager) => {
|
||||
mockGetInstallationInfo.mockReturnValue({
|
||||
|
||||
@@ -87,9 +87,12 @@ export function handleAutoUpdate(
|
||||
);
|
||||
|
||||
if (
|
||||
[PackageManager.NPX, PackageManager.PNPX, PackageManager.BUNX].includes(
|
||||
installationInfo.packageManager,
|
||||
)
|
||||
[
|
||||
PackageManager.NPX,
|
||||
PackageManager.PNPX,
|
||||
PackageManager.BUNX,
|
||||
PackageManager.BINARY,
|
||||
].includes(installationInfo.packageManager)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,19 @@ describe('getInstallationInfo', () => {
|
||||
process.argv = originalArgv;
|
||||
});
|
||||
|
||||
it('should detect running as a standalone binary', () => {
|
||||
vi.stubEnv('IS_BINARY', 'true');
|
||||
process.argv[1] = '/path/to/binary';
|
||||
const info = getInstallationInfo(projectRoot, true);
|
||||
expect(info.packageManager).toBe(PackageManager.BINARY);
|
||||
expect(info.isGlobal).toBe(true);
|
||||
expect(info.updateMessage).toBe(
|
||||
'Running as a standalone binary. Please update by downloading the latest version from GitHub.',
|
||||
);
|
||||
expect(info.updateCommand).toBeUndefined();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should return UNKNOWN when cliPath is not available', () => {
|
||||
process.argv[1] = '';
|
||||
const info = getInstallationInfo(projectRoot, true);
|
||||
|
||||
@@ -21,6 +21,7 @@ export enum PackageManager {
|
||||
BUNX = 'bunx',
|
||||
HOMEBREW = 'homebrew',
|
||||
NPX = 'npx',
|
||||
BINARY = 'binary',
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
|
||||
@@ -41,6 +42,16 @@ export function getInstallationInfo(
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for standalone binary first
|
||||
if (process.env['IS_BINARY'] === 'true') {
|
||||
return {
|
||||
packageManager: PackageManager.BINARY,
|
||||
isGlobal: true,
|
||||
updateMessage:
|
||||
'Running as a standalone binary. Please update by downloading the latest version from GitHub.',
|
||||
};
|
||||
}
|
||||
|
||||
// Normalize path separators to forward slashes for consistent matching.
|
||||
const realPath = fs.realpathSync(cliPath).replace(/\\/g, '/');
|
||||
const normalizedProjectRoot = projectRoot?.replace(/\\/g, '/');
|
||||
|
||||
@@ -69,21 +69,15 @@ describe('geminiPartsToContentParts', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts functionCall parts to text with metadata', () => {
|
||||
it('skips functionCall parts', () => {
|
||||
const parts: Part[] = [
|
||||
{ functionCall: { name: 'myFunc', args: { key: 'value' } } },
|
||||
];
|
||||
const result = geminiPartsToContentParts(parts);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.type).toBe('text');
|
||||
expect(result[0]?._meta).toEqual({ partType: 'functionCall' });
|
||||
const parsed = JSON.parse(
|
||||
(result[0] as { type: 'text'; text: string }).text,
|
||||
);
|
||||
expect(parsed.functionCall.name).toBe('myFunc');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('converts functionResponse parts to text with metadata', () => {
|
||||
it('skips functionResponse parts', () => {
|
||||
const parts: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
@@ -93,9 +87,7 @@ describe('geminiPartsToContentParts', () => {
|
||||
},
|
||||
];
|
||||
const result = geminiPartsToContentParts(parts);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.type).toBe('text');
|
||||
expect(result[0]?._meta).toEqual({ partType: 'functionResponse' });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('serializes unknown part types to text with _meta', () => {
|
||||
|
||||
@@ -40,28 +40,9 @@ export function geminiPartsToContentParts(parts: Part[]): ContentPart[] {
|
||||
mimeType: part.fileData.mimeType,
|
||||
});
|
||||
} else if ('functionCall' in part && part.functionCall) {
|
||||
// Function calls are serialized to text so consumers can inspect them
|
||||
result.push({
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
functionCall: {
|
||||
name: part.functionCall.name,
|
||||
args: part.functionCall.args,
|
||||
},
|
||||
}),
|
||||
_meta: { partType: 'functionCall' },
|
||||
});
|
||||
continue; // Skip function calls, they are emitted as distinct tool_request events
|
||||
} else if ('functionResponse' in part && part.functionResponse) {
|
||||
result.push({
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
functionResponse: {
|
||||
name: part.functionResponse.name,
|
||||
response: part.functionResponse.response,
|
||||
},
|
||||
}),
|
||||
_meta: { partType: 'functionResponse' },
|
||||
});
|
||||
continue; // Skip function responses, they are tied to tool_response events
|
||||
} else {
|
||||
// Fallback: serialize any unrecognized part type to text
|
||||
result.push({
|
||||
|
||||
@@ -1,705 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { FinishReason } from '@google/genai';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
import {
|
||||
translateEvent,
|
||||
createTranslationState,
|
||||
mapFinishReason,
|
||||
mapHttpToGrpcStatus,
|
||||
mapError,
|
||||
mapUsage,
|
||||
type TranslationState,
|
||||
} from './event-translator.js';
|
||||
import { GeminiEventType } from '../core/turn.js';
|
||||
import type { ServerGeminiStreamEvent } from '../core/turn.js';
|
||||
import type { AgentEvent } from './types.js';
|
||||
|
||||
describe('createTranslationState', () => {
|
||||
it('creates state with default streamId', () => {
|
||||
const state = createTranslationState();
|
||||
expect(state.streamId).toBeDefined();
|
||||
expect(state.streamStartEmitted).toBe(false);
|
||||
expect(state.model).toBeUndefined();
|
||||
expect(state.eventCounter).toBe(0);
|
||||
expect(state.pendingToolNames.size).toBe(0);
|
||||
});
|
||||
|
||||
it('creates state with custom streamId', () => {
|
||||
const state = createTranslationState('custom-stream');
|
||||
expect(state.streamId).toBe('custom-stream');
|
||||
});
|
||||
});
|
||||
|
||||
describe('translateEvent', () => {
|
||||
let state: TranslationState;
|
||||
|
||||
beforeEach(() => {
|
||||
state = createTranslationState('test-stream');
|
||||
});
|
||||
|
||||
describe('Content events', () => {
|
||||
it('emits stream_start + message for first content event', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hello world',
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]?.type).toBe('stream_start');
|
||||
expect(result[1]?.type).toBe('message');
|
||||
const msg = result[1] as AgentEvent<'message'>;
|
||||
expect(msg.role).toBe('agent');
|
||||
expect(msg.content).toEqual([{ type: 'text', text: 'Hello world' }]);
|
||||
});
|
||||
|
||||
it('skips stream_start for subsequent content events', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Content,
|
||||
value: 'more text',
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.type).toBe('message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Thought events', () => {
|
||||
it('emits thought content with metadata', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Thought,
|
||||
value: { subject: 'Planning', description: 'I am thinking...' },
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const msg = result[0] as AgentEvent<'message'>;
|
||||
expect(msg.content).toEqual([
|
||||
{ type: 'thought', thought: 'I am thinking...' },
|
||||
]);
|
||||
expect(msg._meta?.['subject']).toBe('Planning');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolCallRequest events', () => {
|
||||
it('emits tool_request and tracks pending tool name', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'call-1',
|
||||
name: 'read_file',
|
||||
args: { path: '/tmp/test' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'p1',
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const req = result[0] as AgentEvent<'tool_request'>;
|
||||
expect(req.requestId).toBe('call-1');
|
||||
expect(req.name).toBe('read_file');
|
||||
expect(req.args).toEqual({ path: '/tmp/test' });
|
||||
expect(state.pendingToolNames.get('call-1')).toBe('read_file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolCallResponse events', () => {
|
||||
it('emits tool_response with content from responseParts', () => {
|
||||
state.streamStartEmitted = true;
|
||||
state.pendingToolNames.set('call-1', 'read_file');
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallResponse,
|
||||
value: {
|
||||
callId: 'call-1',
|
||||
responseParts: [{ text: 'file contents' }],
|
||||
resultDisplay: undefined,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const resp = result[0] as AgentEvent<'tool_response'>;
|
||||
expect(resp.requestId).toBe('call-1');
|
||||
expect(resp.name).toBe('read_file');
|
||||
expect(resp.content).toEqual([{ type: 'text', text: 'file contents' }]);
|
||||
expect(resp.isError).toBe(false);
|
||||
expect(state.pendingToolNames.has('call-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('uses error.message for content when tool errored', () => {
|
||||
state.streamStartEmitted = true;
|
||||
state.pendingToolNames.set('call-2', 'write_file');
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallResponse,
|
||||
value: {
|
||||
callId: 'call-2',
|
||||
responseParts: [{ text: 'stale parts' }],
|
||||
resultDisplay: 'Permission denied',
|
||||
error: new Error('Permission denied to write'),
|
||||
errorType: ToolErrorType.PERMISSION_DENIED,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const resp = result[0] as AgentEvent<'tool_response'>;
|
||||
expect(resp.isError).toBe(true);
|
||||
// Should use error.message, not responseParts
|
||||
expect(resp.content).toEqual([
|
||||
{ type: 'text', text: 'Permission denied to write' },
|
||||
]);
|
||||
expect(resp.displayContent).toEqual([
|
||||
{ type: 'text', text: 'Permission denied' },
|
||||
]);
|
||||
expect(resp.data).toEqual({ errorType: 'permission_denied' });
|
||||
});
|
||||
|
||||
it('uses "unknown" name for untracked tool calls', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallResponse,
|
||||
value: {
|
||||
callId: 'untracked',
|
||||
responseParts: [{ text: 'data' }],
|
||||
resultDisplay: undefined,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
const resp = result[0] as AgentEvent<'tool_response'>;
|
||||
expect(resp.name).toBe('unknown');
|
||||
});
|
||||
|
||||
it('stringifies object resultDisplay correctly', () => {
|
||||
state.streamStartEmitted = true;
|
||||
state.pendingToolNames.set('call-3', 'diff_tool');
|
||||
const objectDisplay = { type: 'FileDiff', before: 'a', after: 'b' };
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallResponse,
|
||||
value: {
|
||||
callId: 'call-3',
|
||||
responseParts: [{ text: 'diff result' }],
|
||||
resultDisplay: objectDisplay,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
const resp = result[0] as AgentEvent<'tool_response'>;
|
||||
expect(resp.displayContent).toEqual([
|
||||
{ type: 'text', text: JSON.stringify(objectDisplay) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes through string resultDisplay as-is', () => {
|
||||
state.streamStartEmitted = true;
|
||||
state.pendingToolNames.set('call-4', 'shell');
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallResponse,
|
||||
value: {
|
||||
callId: 'call-4',
|
||||
responseParts: [{ text: 'output' }],
|
||||
resultDisplay: 'Command output text',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
const resp = result[0] as AgentEvent<'tool_response'>;
|
||||
expect(resp.displayContent).toEqual([
|
||||
{ type: 'text', text: 'Command output text' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves outputFile and contentLength in data', () => {
|
||||
state.streamStartEmitted = true;
|
||||
state.pendingToolNames.set('call-5', 'write_file');
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallResponse,
|
||||
value: {
|
||||
callId: 'call-5',
|
||||
responseParts: [{ text: 'written' }],
|
||||
resultDisplay: undefined,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
outputFile: '/tmp/out.txt',
|
||||
contentLength: 42,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
const resp = result[0] as AgentEvent<'tool_response'>;
|
||||
expect(resp.data?.['outputFile']).toBe('/tmp/out.txt');
|
||||
expect(resp.data?.['contentLength']).toBe(42);
|
||||
});
|
||||
|
||||
it('handles multi-part responses (text + inlineData)', () => {
|
||||
state.streamStartEmitted = true;
|
||||
state.pendingToolNames.set('call-6', 'screenshot');
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallResponse,
|
||||
value: {
|
||||
callId: 'call-6',
|
||||
responseParts: [
|
||||
{ text: 'Here is the screenshot' },
|
||||
{ inlineData: { data: 'base64img', mimeType: 'image/png' } },
|
||||
],
|
||||
resultDisplay: undefined,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
const resp = result[0] as AgentEvent<'tool_response'>;
|
||||
expect(resp.content).toEqual([
|
||||
{ type: 'text', text: 'Here is the screenshot' },
|
||||
{ type: 'media', data: 'base64img', mimeType: 'image/png' },
|
||||
]);
|
||||
expect(resp.isError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error events', () => {
|
||||
it('emits error event for structured errors', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Error,
|
||||
value: { error: { message: 'Rate limited', status: 429 } },
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const err = result[0] as AgentEvent<'error'>;
|
||||
expect(err.status).toBe('RESOURCE_EXHAUSTED');
|
||||
expect(err.message).toBe('Rate limited');
|
||||
expect(err.fatal).toBe(true);
|
||||
});
|
||||
|
||||
it('emits error event for Error instances', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Error,
|
||||
value: { error: new Error('Something broke') },
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
const err = result[0] as AgentEvent<'error'>;
|
||||
expect(err.status).toBe('INTERNAL');
|
||||
expect(err.message).toBe('Something broke');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ModelInfo events', () => {
|
||||
it('emits stream_start when no stream started yet', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ModelInfo,
|
||||
value: 'gemini-2.5-pro',
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.type).toBe('stream_start');
|
||||
expect(state.model).toBe('gemini-2.5-pro');
|
||||
expect(state.streamStartEmitted).toBe(true);
|
||||
});
|
||||
|
||||
it('emits session_update when stream already started', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ModelInfo,
|
||||
value: 'gemini-2.5-flash',
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.type).toBe('session_update');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentExecutionStopped events', () => {
|
||||
it('emits stream_end with the final stop message in data.message', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.AgentExecutionStopped,
|
||||
value: {
|
||||
reason: 'before_model',
|
||||
systemMessage: 'Stopped by hook',
|
||||
contextCleared: true,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const streamEnd = result[0] as AgentEvent<'stream_end'>;
|
||||
expect(streamEnd.type).toBe('stream_end');
|
||||
expect(streamEnd.reason).toBe('completed');
|
||||
expect(streamEnd.data).toEqual({ message: 'Stopped by hook' });
|
||||
});
|
||||
|
||||
it('uses reason when systemMessage is not set', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.AgentExecutionStopped,
|
||||
value: { reason: 'hook' },
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const streamEnd = result[0] as AgentEvent<'stream_end'>;
|
||||
expect(streamEnd.data).toEqual({ message: 'hook' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentExecutionBlocked events', () => {
|
||||
it('emits non-fatal error event (non-terminal, stream continues)', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: { reason: 'Policy violation' },
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const err = result[0] as AgentEvent<'error'>;
|
||||
expect(err.type).toBe('error');
|
||||
expect(err.fatal).toBe(false);
|
||||
expect(err._meta?.['code']).toBe('AGENT_EXECUTION_BLOCKED');
|
||||
expect(err.message).toBe('Agent execution blocked: Policy violation');
|
||||
});
|
||||
|
||||
it('uses systemMessage in the final error message when available', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: {
|
||||
reason: 'hook_blocked',
|
||||
systemMessage: 'Blocked by policy hook',
|
||||
contextCleared: true,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
const err = result[0] as AgentEvent<'error'>;
|
||||
expect(err.message).toBe(
|
||||
'Agent execution blocked: Blocked by policy hook',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoopDetected events', () => {
|
||||
it('emits a custom loop_detected event', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.LoopDetected,
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.type).toBe('custom');
|
||||
expect((result[0] as AgentEvent<'custom'>).kind).toBe('loop_detected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MaxSessionTurns events', () => {
|
||||
it('emits a non-fatal max-turns error event', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.MaxSessionTurns,
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const err = result[0] as AgentEvent<'error'>;
|
||||
expect(err.type).toBe('error');
|
||||
expect(err.fatal).toBe(false);
|
||||
expect(err.status).toBe('RESOURCE_EXHAUSTED');
|
||||
expect(err._meta?.['code']).toBe('MAX_TURNS_EXCEEDED');
|
||||
expect(err.message).toBe('Maximum session turns exceeded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Finished events', () => {
|
||||
it('emits usage + stream_end for STOP', () => {
|
||||
state.streamStartEmitted = true;
|
||||
state.model = 'gemini-2.5-pro';
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Finished,
|
||||
value: {
|
||||
reason: FinishReason.STOP,
|
||||
usageMetadata: {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
cachedContentTokenCount: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
const usage = result[0] as AgentEvent<'usage'>;
|
||||
expect(usage.model).toBe('gemini-2.5-pro');
|
||||
expect(usage.inputTokens).toBe(100);
|
||||
expect(usage.outputTokens).toBe(50);
|
||||
expect(usage.cachedTokens).toBe(10);
|
||||
|
||||
const end = result[1] as AgentEvent<'stream_end'>;
|
||||
expect(end.reason).toBe('completed');
|
||||
});
|
||||
|
||||
it('emits stream_end without usage when no metadata', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: undefined },
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.type).toBe('stream_end');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Citation events', () => {
|
||||
it('emits message with citation meta', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Citation,
|
||||
value: 'Source: example.com',
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const msg = result[0] as AgentEvent<'message'>;
|
||||
expect(msg.content).toEqual([
|
||||
{ type: 'text', text: 'Source: example.com' },
|
||||
]);
|
||||
expect(msg._meta?.['citation']).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserCancelled events', () => {
|
||||
it('emits stream_end with reason aborted', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.UserCancelled,
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const end = result[0] as AgentEvent<'stream_end'>;
|
||||
expect(end.type).toBe('stream_end');
|
||||
expect(end.reason).toBe('aborted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContextWindowWillOverflow events', () => {
|
||||
it('emits fatal error', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ContextWindowWillOverflow,
|
||||
value: {
|
||||
estimatedRequestTokenCount: 150000,
|
||||
remainingTokenCount: 10000,
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const err = result[0] as AgentEvent<'error'>;
|
||||
expect(err.status).toBe('RESOURCE_EXHAUSTED');
|
||||
expect(err.fatal).toBe(true);
|
||||
expect(err.message).toContain('150000');
|
||||
expect(err.message).toContain('10000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('InvalidStream events', () => {
|
||||
it('emits fatal error', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.InvalidStream,
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const err = result[0] as AgentEvent<'error'>;
|
||||
expect(err.status).toBe('INTERNAL');
|
||||
expect(err.message).toBe('Invalid stream received from model');
|
||||
expect(err.fatal).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Events with no output', () => {
|
||||
it('returns empty for Retry', () => {
|
||||
const result = translateEvent({ type: GeminiEventType.Retry }, state);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty for ChatCompressed with null', () => {
|
||||
const result = translateEvent(
|
||||
{ type: GeminiEventType.ChatCompressed, value: null },
|
||||
state,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty for ToolCallConfirmation', () => {
|
||||
// ToolCallConfirmation is skipped in non-interactive mode (elicitations
|
||||
// are deferred to the interactive runtime adaptation).
|
||||
const event = {
|
||||
type: GeminiEventType.ToolCallConfirmation,
|
||||
value: {
|
||||
request: {
|
||||
callId: 'c1',
|
||||
name: 'tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'p1',
|
||||
},
|
||||
details: { type: 'info', title: 'Confirm', prompt: 'Confirm?' },
|
||||
},
|
||||
} as ServerGeminiStreamEvent;
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event IDs', () => {
|
||||
it('generates sequential IDs', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const e1 = translateEvent(
|
||||
{ type: GeminiEventType.Content, value: 'a' },
|
||||
state,
|
||||
);
|
||||
const e2 = translateEvent(
|
||||
{ type: GeminiEventType.Content, value: 'b' },
|
||||
state,
|
||||
);
|
||||
expect(e1[0]?.id).toBe('test-stream-0');
|
||||
expect(e2[0]?.id).toBe('test-stream-1');
|
||||
});
|
||||
|
||||
it('includes streamId in events', () => {
|
||||
const events = translateEvent(
|
||||
{ type: GeminiEventType.Content, value: 'hi' },
|
||||
state,
|
||||
);
|
||||
for (const e of events) {
|
||||
expect(e.streamId).toBe('test-stream');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapFinishReason', () => {
|
||||
it('maps STOP to completed', () => {
|
||||
expect(mapFinishReason(FinishReason.STOP)).toBe('completed');
|
||||
});
|
||||
|
||||
it('maps undefined to completed', () => {
|
||||
expect(mapFinishReason(undefined)).toBe('completed');
|
||||
});
|
||||
|
||||
it('maps MAX_TOKENS to max_budget', () => {
|
||||
expect(mapFinishReason(FinishReason.MAX_TOKENS)).toBe('max_budget');
|
||||
});
|
||||
|
||||
it('maps SAFETY to refusal', () => {
|
||||
expect(mapFinishReason(FinishReason.SAFETY)).toBe('refusal');
|
||||
});
|
||||
|
||||
it('maps MALFORMED_FUNCTION_CALL to failed', () => {
|
||||
expect(mapFinishReason(FinishReason.MALFORMED_FUNCTION_CALL)).toBe(
|
||||
'failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps RECITATION to refusal', () => {
|
||||
expect(mapFinishReason(FinishReason.RECITATION)).toBe('refusal');
|
||||
});
|
||||
|
||||
it('maps LANGUAGE to refusal', () => {
|
||||
expect(mapFinishReason(FinishReason.LANGUAGE)).toBe('refusal');
|
||||
});
|
||||
|
||||
it('maps BLOCKLIST to refusal', () => {
|
||||
expect(mapFinishReason(FinishReason.BLOCKLIST)).toBe('refusal');
|
||||
});
|
||||
|
||||
it('maps OTHER to failed', () => {
|
||||
expect(mapFinishReason(FinishReason.OTHER)).toBe('failed');
|
||||
});
|
||||
|
||||
it('maps PROHIBITED_CONTENT to refusal', () => {
|
||||
expect(mapFinishReason(FinishReason.PROHIBITED_CONTENT)).toBe('refusal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapHttpToGrpcStatus', () => {
|
||||
it('maps 400 to INVALID_ARGUMENT', () => {
|
||||
expect(mapHttpToGrpcStatus(400)).toBe('INVALID_ARGUMENT');
|
||||
});
|
||||
|
||||
it('maps 401 to UNAUTHENTICATED', () => {
|
||||
expect(mapHttpToGrpcStatus(401)).toBe('UNAUTHENTICATED');
|
||||
});
|
||||
|
||||
it('maps 429 to RESOURCE_EXHAUSTED', () => {
|
||||
expect(mapHttpToGrpcStatus(429)).toBe('RESOURCE_EXHAUSTED');
|
||||
});
|
||||
|
||||
it('maps undefined to INTERNAL', () => {
|
||||
expect(mapHttpToGrpcStatus(undefined)).toBe('INTERNAL');
|
||||
});
|
||||
|
||||
it('maps unknown codes to INTERNAL', () => {
|
||||
expect(mapHttpToGrpcStatus(418)).toBe('INTERNAL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapError', () => {
|
||||
it('maps structured errors with status', () => {
|
||||
const result = mapError({ message: 'Rate limit', status: 429 });
|
||||
expect(result.status).toBe('RESOURCE_EXHAUSTED');
|
||||
expect(result.message).toBe('Rate limit');
|
||||
expect(result.fatal).toBe(true);
|
||||
});
|
||||
|
||||
it('maps Error instances', () => {
|
||||
const result = mapError(new Error('Something failed'));
|
||||
expect(result.status).toBe('INTERNAL');
|
||||
expect(result.message).toBe('Something failed');
|
||||
});
|
||||
|
||||
it('preserves error name in _meta', () => {
|
||||
class CustomError extends Error {
|
||||
constructor(msg: string) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
const result = mapError(new CustomError('test'));
|
||||
expect(result._meta?.['errorName']).toBe('CustomError');
|
||||
});
|
||||
|
||||
it('maps non-Error values to string', () => {
|
||||
const result = mapError('raw string error');
|
||||
expect(result.message).toBe('raw string error');
|
||||
expect(result.status).toBe('INTERNAL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapUsage', () => {
|
||||
it('maps all fields', () => {
|
||||
const result = mapUsage(
|
||||
{
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
cachedContentTokenCount: 25,
|
||||
},
|
||||
'gemini-2.5-pro',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
model: 'gemini-2.5-pro',
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cachedTokens: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses "unknown" for missing model', () => {
|
||||
const result = mapUsage({});
|
||||
expect(result.model).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -1,456 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Pure, stateless-per-call translation functions that convert
|
||||
* ServerGeminiStreamEvent objects into AgentEvent objects.
|
||||
*
|
||||
* No side effects, no generators. Each call to `translateEvent` takes an event
|
||||
* and mutable TranslationState, returning zero or more AgentEvents.
|
||||
*/
|
||||
|
||||
import type { FinishReason } from '@google/genai';
|
||||
import { GeminiEventType } from '../core/turn.js';
|
||||
import type {
|
||||
ServerGeminiStreamEvent,
|
||||
StructuredError,
|
||||
GeminiFinishedEventValue,
|
||||
} from '../core/turn.js';
|
||||
import type { AgentEvent, StreamEndReason, ErrorData, Usage } from './types.js';
|
||||
import {
|
||||
geminiPartsToContentParts,
|
||||
toolResultDisplayToContentParts,
|
||||
buildToolResponseData,
|
||||
} from './content-utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Translation State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TranslationState {
|
||||
streamId: string;
|
||||
streamStartEmitted: boolean;
|
||||
model: string | undefined;
|
||||
eventCounter: number;
|
||||
/** Tracks callId → tool name from requests so responses can reference the name. */
|
||||
pendingToolNames: Map<string, string>;
|
||||
}
|
||||
|
||||
export function createTranslationState(streamId?: string): TranslationState {
|
||||
return {
|
||||
streamId: streamId ?? crypto.randomUUID(),
|
||||
streamStartEmitted: false,
|
||||
model: undefined,
|
||||
eventCounter: 0,
|
||||
pendingToolNames: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeEvent(
|
||||
type: AgentEvent['type'],
|
||||
state: TranslationState,
|
||||
payload: Partial<AgentEvent>,
|
||||
): AgentEvent {
|
||||
const id = `${state.streamId}-${state.eventCounter++}`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- constructing AgentEvent from common fields + payload
|
||||
return {
|
||||
...payload,
|
||||
id,
|
||||
timestamp: new Date().toISOString(),
|
||||
streamId: state.streamId,
|
||||
type,
|
||||
} as AgentEvent;
|
||||
}
|
||||
|
||||
function ensureStreamStart(state: TranslationState, out: AgentEvent[]): void {
|
||||
if (!state.streamStartEmitted) {
|
||||
out.push(makeEvent('stream_start', state, { streamId: state.streamId }));
|
||||
state.streamStartEmitted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core Translator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Translates a single ServerGeminiStreamEvent into zero or more AgentEvents.
|
||||
* Mutates `state` (counter, flags) as a side effect.
|
||||
*/
|
||||
export function translateEvent(
|
||||
event: ServerGeminiStreamEvent,
|
||||
state: TranslationState,
|
||||
): AgentEvent[] {
|
||||
const out: AgentEvent[] = [];
|
||||
|
||||
switch (event.type) {
|
||||
case GeminiEventType.ModelInfo:
|
||||
state.model = event.value;
|
||||
if (!state.streamStartEmitted) {
|
||||
out.push(
|
||||
makeEvent('stream_start', state, { streamId: state.streamId }),
|
||||
);
|
||||
state.streamStartEmitted = true;
|
||||
} else {
|
||||
out.push(makeEvent('session_update', state, { model: event.value }));
|
||||
}
|
||||
break;
|
||||
|
||||
case GeminiEventType.Content:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('message', state, {
|
||||
role: 'agent',
|
||||
content: [{ type: 'text', text: event.value }],
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.Thought:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('message', state, {
|
||||
role: 'agent',
|
||||
content: [{ type: 'thought', thought: event.value.description }],
|
||||
_meta: event.value.subject
|
||||
? { source: 'agent', subject: event.value.subject }
|
||||
: { source: 'agent' },
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.Citation:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('message', state, {
|
||||
role: 'agent',
|
||||
content: [{ type: 'text', text: event.value }],
|
||||
_meta: { source: 'agent', citation: true },
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.Finished:
|
||||
handleFinished(event.value, state, out);
|
||||
break;
|
||||
|
||||
case GeminiEventType.Error:
|
||||
handleError(event.value.error, state, out);
|
||||
break;
|
||||
|
||||
case GeminiEventType.UserCancelled:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('stream_end', state, {
|
||||
streamId: state.streamId,
|
||||
reason: 'aborted',
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.MaxSessionTurns:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('error', state, {
|
||||
status: 'RESOURCE_EXHAUSTED',
|
||||
message: 'Maximum session turns exceeded',
|
||||
fatal: false,
|
||||
_meta: { code: 'MAX_TURNS_EXCEEDED' },
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.LoopDetected:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('custom', state, {
|
||||
kind: 'loop_detected',
|
||||
}),
|
||||
);
|
||||
// No stream_end — the stream continues. Consumer decides how to handle:
|
||||
// non-interactive emits a warning, interactive shows a confirmation dialog.
|
||||
break;
|
||||
|
||||
case GeminiEventType.ContextWindowWillOverflow:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('error', state, {
|
||||
status: 'RESOURCE_EXHAUSTED',
|
||||
message: `Context window will overflow (estimated: ${event.value.estimatedRequestTokenCount}, remaining: ${event.value.remainingTokenCount})`,
|
||||
fatal: true,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.AgentExecutionStopped:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('stream_end', state, {
|
||||
streamId: state.streamId,
|
||||
reason: 'completed',
|
||||
data: {
|
||||
message: event.value.systemMessage?.trim() || event.value.reason,
|
||||
},
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.AgentExecutionBlocked:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('error', state, {
|
||||
status: 'PERMISSION_DENIED',
|
||||
message: `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`,
|
||||
fatal: false,
|
||||
_meta: { code: 'AGENT_EXECUTION_BLOCKED' },
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.InvalidStream:
|
||||
ensureStreamStart(state, out);
|
||||
out.push(
|
||||
makeEvent('error', state, {
|
||||
status: 'INTERNAL',
|
||||
message: 'Invalid stream received from model',
|
||||
fatal: true,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.ToolCallRequest:
|
||||
ensureStreamStart(state, out);
|
||||
state.pendingToolNames.set(event.value.callId, event.value.name);
|
||||
out.push(
|
||||
makeEvent('tool_request', state, {
|
||||
requestId: event.value.callId,
|
||||
name: event.value.name,
|
||||
args: event.value.args,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case GeminiEventType.ToolCallResponse: {
|
||||
ensureStreamStart(state, out);
|
||||
const displayContent = toolResultDisplayToContentParts(
|
||||
event.value.resultDisplay,
|
||||
);
|
||||
const data = buildToolResponseData(event.value);
|
||||
out.push(
|
||||
makeEvent('tool_response', state, {
|
||||
requestId: event.value.callId,
|
||||
name: state.pendingToolNames.get(event.value.callId) ?? 'unknown',
|
||||
content: event.value.error
|
||||
? [{ type: 'text', text: event.value.error.message }]
|
||||
: geminiPartsToContentParts(event.value.responseParts),
|
||||
isError: event.value.error !== undefined,
|
||||
...(displayContent ? { displayContent } : {}),
|
||||
...(data ? { data } : {}),
|
||||
}),
|
||||
);
|
||||
state.pendingToolNames.delete(event.value.callId);
|
||||
break;
|
||||
}
|
||||
|
||||
case GeminiEventType.ToolCallConfirmation:
|
||||
// Elicitations are handled separately by the session layer
|
||||
break;
|
||||
|
||||
// Internal concerns — no AgentEvent emitted
|
||||
case GeminiEventType.ChatCompressed:
|
||||
case GeminiEventType.Retry:
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Finished Event Handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleFinished(
|
||||
value: GeminiFinishedEventValue,
|
||||
state: TranslationState,
|
||||
out: AgentEvent[],
|
||||
): void {
|
||||
ensureStreamStart(state, out);
|
||||
|
||||
if (value.usageMetadata) {
|
||||
const usage = mapUsage(value.usageMetadata, state.model);
|
||||
out.push(makeEvent('usage', state, usage));
|
||||
}
|
||||
|
||||
out.push(
|
||||
makeEvent('stream_end', state, {
|
||||
streamId: state.streamId,
|
||||
reason: mapFinishReason(value.reason),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error Handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleError(
|
||||
error: unknown,
|
||||
state: TranslationState,
|
||||
out: AgentEvent[],
|
||||
): void {
|
||||
ensureStreamStart(state, out);
|
||||
|
||||
const mapped = mapError(error);
|
||||
out.push(makeEvent('error', state, mapped));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public Mapping Functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Maps a Gemini FinishReason to a StreamEndReason.
|
||||
*/
|
||||
export function mapFinishReason(
|
||||
reason: FinishReason | undefined,
|
||||
): StreamEndReason {
|
||||
if (!reason) return 'completed';
|
||||
|
||||
switch (reason) {
|
||||
case 'STOP':
|
||||
case 'FINISH_REASON_UNSPECIFIED':
|
||||
return 'completed';
|
||||
case 'MAX_TOKENS':
|
||||
return 'max_budget';
|
||||
case 'SAFETY':
|
||||
case 'RECITATION':
|
||||
case 'LANGUAGE':
|
||||
case 'BLOCKLIST':
|
||||
case 'PROHIBITED_CONTENT':
|
||||
case 'SPII':
|
||||
return 'refusal';
|
||||
case 'MALFORMED_FUNCTION_CALL':
|
||||
case 'OTHER':
|
||||
return 'failed';
|
||||
default:
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an HTTP status code to a gRPC-style status string.
|
||||
*/
|
||||
export function mapHttpToGrpcStatus(
|
||||
httpStatus: number | undefined,
|
||||
): ErrorData['status'] {
|
||||
if (httpStatus === undefined) return 'INTERNAL';
|
||||
|
||||
switch (httpStatus) {
|
||||
case 400:
|
||||
return 'INVALID_ARGUMENT';
|
||||
case 401:
|
||||
return 'UNAUTHENTICATED';
|
||||
case 403:
|
||||
return 'PERMISSION_DENIED';
|
||||
case 404:
|
||||
return 'NOT_FOUND';
|
||||
case 409:
|
||||
return 'ALREADY_EXISTS';
|
||||
case 429:
|
||||
return 'RESOURCE_EXHAUSTED';
|
||||
case 500:
|
||||
return 'INTERNAL';
|
||||
case 501:
|
||||
return 'UNIMPLEMENTED';
|
||||
case 503:
|
||||
return 'UNAVAILABLE';
|
||||
case 504:
|
||||
return 'DEADLINE_EXCEEDED';
|
||||
default:
|
||||
return 'INTERNAL';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a StructuredError (or unknown error value) to an ErrorData payload.
|
||||
* Review fix #4: preserves error metadata (name, code, stack) in _meta.
|
||||
*/
|
||||
export function mapError(
|
||||
error: unknown,
|
||||
): ErrorData & { _meta?: Record<string, unknown> } {
|
||||
const meta: Record<string, unknown> = {};
|
||||
|
||||
if (error instanceof Error) {
|
||||
meta['errorName'] = error.constructor.name;
|
||||
if ('exitCode' in error && typeof error.exitCode === 'number') {
|
||||
meta['exitCode'] = error.exitCode;
|
||||
}
|
||||
if ('code' in error) {
|
||||
meta['code'] = error.code;
|
||||
}
|
||||
}
|
||||
|
||||
const hasMeta = Object.keys(meta).length > 0;
|
||||
|
||||
if (isStructuredError(error)) {
|
||||
return {
|
||||
status: mapHttpToGrpcStatus(error.status),
|
||||
message: error.message,
|
||||
fatal: true,
|
||||
...(hasMeta ? { _meta: meta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
status: 'INTERNAL',
|
||||
message: error.message,
|
||||
fatal: true,
|
||||
...(hasMeta ? { _meta: meta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'INTERNAL',
|
||||
message: String(error),
|
||||
fatal: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isStructuredError(error: unknown): error is StructuredError {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'message' in error &&
|
||||
typeof error.message === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps Gemini usageMetadata to Usage.
|
||||
*/
|
||||
export function mapUsage(
|
||||
metadata: {
|
||||
promptTokenCount?: number;
|
||||
candidatesTokenCount?: number;
|
||||
cachedContentTokenCount?: number;
|
||||
},
|
||||
model?: string,
|
||||
): Usage {
|
||||
return {
|
||||
model: model ?? 'unknown',
|
||||
inputTokens: metadata.promptTokenCount,
|
||||
outputTokens: metadata.candidatesTokenCount,
|
||||
cachedTokens: metadata.cachedContentTokenCount,
|
||||
};
|
||||
}
|
||||
@@ -1,870 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { FinishReason } from '@google/genai';
|
||||
import { LegacyAgentSession } from './legacy-agent-session.js';
|
||||
import type { LegacySessionDeps } from './legacy-agent-session.js';
|
||||
import { GeminiEventType } from '../core/turn.js';
|
||||
import type { ServerGeminiStreamEvent } from '../core/turn.js';
|
||||
import type { AgentEvent } from './types.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
import type {
|
||||
CompletedToolCall,
|
||||
ToolCallRequestInfo,
|
||||
} from '../scheduler/types.js';
|
||||
import { CoreToolCallStatus } from '../scheduler/types.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMockDeps(
|
||||
overrides?: Partial<LegacySessionDeps>,
|
||||
): LegacySessionDeps {
|
||||
const mockClient = {
|
||||
sendMessageStream: vi.fn(),
|
||||
getChat: vi.fn().mockReturnValue({
|
||||
recordCompletedToolCalls: vi.fn(),
|
||||
}),
|
||||
getCurrentSequenceModel: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
|
||||
const mockScheduler = {
|
||||
schedule: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const mockConfig = {
|
||||
getMaxSessionTurns: vi.fn().mockReturnValue(-1),
|
||||
getModel: vi.fn().mockReturnValue('gemini-2.5-pro'),
|
||||
};
|
||||
|
||||
return {
|
||||
client: mockClient as unknown as LegacySessionDeps['client'],
|
||||
|
||||
scheduler: mockScheduler as unknown as LegacySessionDeps['scheduler'],
|
||||
|
||||
config: mockConfig as unknown as LegacySessionDeps['config'],
|
||||
promptId: 'test-prompt',
|
||||
streamId: 'test-stream',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function* makeStream(
|
||||
events: ServerGeminiStreamEvent[],
|
||||
): AsyncGenerator<ServerGeminiStreamEvent> {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
function makeToolRequest(callId: string, name: string): ToolCallRequestInfo {
|
||||
return {
|
||||
callId,
|
||||
name,
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'p1',
|
||||
};
|
||||
}
|
||||
|
||||
function makeCompletedToolCall(
|
||||
callId: string,
|
||||
name: string,
|
||||
responseText: string,
|
||||
): CompletedToolCall {
|
||||
return {
|
||||
status: CoreToolCallStatus.Success,
|
||||
request: makeToolRequest(callId, name),
|
||||
response: {
|
||||
callId,
|
||||
responseParts: [{ text: responseText }],
|
||||
resultDisplay: undefined,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
|
||||
tool: {} as CompletedToolCall extends { tool: infer T } ? T : never,
|
||||
|
||||
invocation: {} as CompletedToolCall extends { invocation: infer T }
|
||||
? T
|
||||
: never,
|
||||
} as CompletedToolCall;
|
||||
}
|
||||
|
||||
async function collectEvents(
|
||||
session: LegacyAgentSession,
|
||||
): Promise<AgentEvent[]> {
|
||||
const events: AgentEvent[] = [];
|
||||
for await (const event of session.stream()) {
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('LegacyAgentSession', () => {
|
||||
let deps: LegacySessionDeps;
|
||||
|
||||
beforeEach(() => {
|
||||
deps = createMockDeps();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
describe('send', () => {
|
||||
it('returns streamId', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
sendMock.mockReturnValue(
|
||||
makeStream([
|
||||
{ type: GeminiEventType.Content, value: 'hello' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: FinishReason.STOP, usageMetadata: undefined },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
const result = await session.send({
|
||||
message: [{ type: 'text', text: 'hi' }],
|
||||
});
|
||||
|
||||
expect(result.streamId).toBe('test-stream');
|
||||
});
|
||||
|
||||
it('throws for non-message payloads', async () => {
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await expect(session.send({ update: { title: 'test' } })).rejects.toThrow(
|
||||
'only supports message sends',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stream - basic flow', () => {
|
||||
it('emits stream_start, content messages, and stream_end', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
sendMock.mockReturnValue(
|
||||
makeStream([
|
||||
{ type: GeminiEventType.Content, value: 'Hello' },
|
||||
{ type: GeminiEventType.Content, value: ' World' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: FinishReason.STOP, usageMetadata: undefined },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const types = events.map((e) => e.type);
|
||||
expect(types).toContain('stream_start');
|
||||
expect(types).toContain('message');
|
||||
expect(types).toContain('stream_end');
|
||||
|
||||
const messages = events.filter(
|
||||
(e): e is AgentEvent<'message'> =>
|
||||
e.type === 'message' && e.role === 'agent',
|
||||
);
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]?.content).toEqual([{ type: 'text', text: 'Hello' }]);
|
||||
|
||||
const streamEnd = events.find(
|
||||
(e): e is AgentEvent<'stream_end'> => e.type === 'stream_end',
|
||||
);
|
||||
expect(streamEnd?.reason).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stream - tool calls', () => {
|
||||
it('handles a tool call round-trip', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
// First turn: model requests a tool
|
||||
sendMock.mockReturnValueOnce(
|
||||
makeStream([
|
||||
{
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: makeToolRequest('call-1', 'read_file'),
|
||||
},
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: FinishReason.STOP, usageMetadata: undefined },
|
||||
},
|
||||
]),
|
||||
);
|
||||
// Second turn: model provides final answer
|
||||
sendMock.mockReturnValueOnce(
|
||||
makeStream([
|
||||
{ type: GeminiEventType.Content, value: 'Done!' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: FinishReason.STOP, usageMetadata: undefined },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const scheduleMock = deps.scheduler.schedule as ReturnType<typeof vi.fn>;
|
||||
scheduleMock.mockResolvedValueOnce([
|
||||
makeCompletedToolCall('call-1', 'read_file', 'file contents'),
|
||||
]);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'read a file' }] });
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const types = events.map((e) => e.type);
|
||||
expect(types).toContain('tool_request');
|
||||
expect(types).toContain('tool_response');
|
||||
expect(types).toContain('stream_end');
|
||||
|
||||
const toolReq = events.find(
|
||||
(e): e is AgentEvent<'tool_request'> => e.type === 'tool_request',
|
||||
);
|
||||
expect(toolReq?.name).toBe('read_file');
|
||||
|
||||
const toolResp = events.find(
|
||||
(e): e is AgentEvent<'tool_response'> => e.type === 'tool_response',
|
||||
);
|
||||
expect(toolResp?.name).toBe('read_file');
|
||||
expect(toolResp?.content).toEqual([
|
||||
{ type: 'text', text: 'file contents' },
|
||||
]);
|
||||
expect(toolResp?.isError).toBe(false);
|
||||
|
||||
// Should have called sendMessageStream twice
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handles tool errors and sends error message in content', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
sendMock.mockReturnValueOnce(
|
||||
makeStream([
|
||||
{
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: makeToolRequest('call-1', 'write_file'),
|
||||
},
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: FinishReason.STOP, usageMetadata: undefined },
|
||||
},
|
||||
]),
|
||||
);
|
||||
sendMock.mockReturnValueOnce(
|
||||
makeStream([
|
||||
{ type: GeminiEventType.Content, value: 'Failed' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: FinishReason.STOP, usageMetadata: undefined },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const errorToolCall: CompletedToolCall = {
|
||||
status: CoreToolCallStatus.Error,
|
||||
request: makeToolRequest('call-1', 'write_file'),
|
||||
response: {
|
||||
callId: 'call-1',
|
||||
responseParts: [{ text: 'stale' }],
|
||||
resultDisplay: 'Error display',
|
||||
error: new Error('Permission denied'),
|
||||
errorType: 'permission_denied',
|
||||
},
|
||||
} as CompletedToolCall;
|
||||
|
||||
const scheduleMock = deps.scheduler.schedule as ReturnType<typeof vi.fn>;
|
||||
scheduleMock.mockResolvedValueOnce([errorToolCall]);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({
|
||||
message: [{ type: 'text', text: 'write file' }],
|
||||
});
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const toolResp = events.find(
|
||||
(e): e is AgentEvent<'tool_response'> => e.type === 'tool_response',
|
||||
);
|
||||
expect(toolResp?.isError).toBe(true);
|
||||
// Uses error.message, not responseParts
|
||||
expect(toolResp?.content).toEqual([
|
||||
{ type: 'text', text: 'Permission denied' },
|
||||
]);
|
||||
expect(toolResp?.displayContent).toEqual([
|
||||
{ type: 'text', text: 'Error display' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('stops on STOP_EXECUTION tool error', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
sendMock.mockReturnValueOnce(
|
||||
makeStream([
|
||||
{
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: makeToolRequest('call-1', 'dangerous_tool'),
|
||||
},
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: FinishReason.STOP, usageMetadata: undefined },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const stopToolCall: CompletedToolCall = {
|
||||
status: CoreToolCallStatus.Error,
|
||||
request: makeToolRequest('call-1', 'dangerous_tool'),
|
||||
response: {
|
||||
callId: 'call-1',
|
||||
responseParts: [],
|
||||
resultDisplay: undefined,
|
||||
error: new Error('Stopped by policy'),
|
||||
errorType: ToolErrorType.STOP_EXECUTION,
|
||||
},
|
||||
} as CompletedToolCall;
|
||||
|
||||
const scheduleMock = deps.scheduler.schedule as ReturnType<typeof vi.fn>;
|
||||
scheduleMock.mockResolvedValueOnce([stopToolCall]);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({
|
||||
message: [{ type: 'text', text: 'do something' }],
|
||||
});
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const streamEnd = events.find(
|
||||
(e): e is AgentEvent<'stream_end'> => e.type === 'stream_end',
|
||||
);
|
||||
expect(streamEnd?.reason).toBe('completed');
|
||||
// Should NOT make a second call
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stream - terminal events', () => {
|
||||
it('handles AgentExecutionStopped', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
sendMock.mockReturnValue(
|
||||
makeStream([
|
||||
{
|
||||
type: GeminiEventType.AgentExecutionStopped,
|
||||
value: { reason: 'hook', systemMessage: 'Halted by hook' },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const streamEnd = events.find(
|
||||
(e): e is AgentEvent<'stream_end'> => e.type === 'stream_end',
|
||||
);
|
||||
expect(streamEnd?.reason).toBe('completed');
|
||||
expect(streamEnd?.data).toEqual({ message: 'Halted by hook' });
|
||||
});
|
||||
|
||||
it('handles AgentExecutionBlocked as non-terminal and continues the stream', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
sendMock.mockReturnValue(
|
||||
makeStream([
|
||||
{
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: { reason: 'Blocked by hook' },
|
||||
},
|
||||
{ type: GeminiEventType.Content, value: 'Final answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: FinishReason.STOP, usageMetadata: undefined },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const blocked = events.find(
|
||||
(e): e is AgentEvent<'error'> =>
|
||||
e.type === 'error' && e._meta?.['code'] === 'AGENT_EXECUTION_BLOCKED',
|
||||
);
|
||||
expect(blocked?.fatal).toBe(false);
|
||||
expect(blocked?.message).toBe('Agent execution blocked: Blocked by hook');
|
||||
|
||||
const messages = events.filter(
|
||||
(e): e is AgentEvent<'message'> =>
|
||||
e.type === 'message' && e.role === 'agent',
|
||||
);
|
||||
expect(
|
||||
messages.some(
|
||||
(message) =>
|
||||
message.content[0]?.type === 'text' &&
|
||||
message.content[0].text === 'Final answer',
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const streamEnd = events.find(
|
||||
(e): e is AgentEvent<'stream_end'> => e.type === 'stream_end',
|
||||
);
|
||||
expect(streamEnd?.reason).toBe('completed');
|
||||
});
|
||||
|
||||
it('handles Error events', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
sendMock.mockReturnValue(
|
||||
makeStream([
|
||||
{
|
||||
type: GeminiEventType.Error,
|
||||
value: { error: new Error('API error') },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const err = events.find(
|
||||
(e): e is AgentEvent<'error'> => e.type === 'error',
|
||||
);
|
||||
expect(err?.message).toBe('API error');
|
||||
expect(events.some((e) => e.type === 'stream_end')).toBe(true);
|
||||
});
|
||||
|
||||
it('handles LoopDetected as non-terminal custom event', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
// LoopDetected followed by more content — stream continues
|
||||
sendMock.mockReturnValue(
|
||||
makeStream([
|
||||
{ type: GeminiEventType.LoopDetected },
|
||||
{ type: GeminiEventType.Content, value: 'continuing after loop' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: FinishReason.STOP, usageMetadata: undefined },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
const events = await collectEvents(session);
|
||||
|
||||
// Should have a custom loop_detected event
|
||||
const custom = events.find(
|
||||
(e): e is AgentEvent<'custom'> =>
|
||||
e.type === 'custom' && e.kind === 'loop_detected',
|
||||
);
|
||||
expect(custom).toBeDefined();
|
||||
|
||||
// Stream should have continued — content after loop detected
|
||||
const messages = events.filter(
|
||||
(e): e is AgentEvent<'message'> =>
|
||||
e.type === 'message' && e.role === 'agent',
|
||||
);
|
||||
expect(
|
||||
messages.some(
|
||||
(m) =>
|
||||
m.content[0]?.type === 'text' &&
|
||||
m.content[0].text === 'continuing after loop',
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// Should still end with stream_end completed
|
||||
const streamEnd = events.find(
|
||||
(e): e is AgentEvent<'stream_end'> => e.type === 'stream_end',
|
||||
);
|
||||
expect(streamEnd?.reason).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stream - max turns', () => {
|
||||
it('emits stream_end with max_turns when the session turn limit is exceeded', async () => {
|
||||
const configMock = deps.config.getMaxSessionTurns as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
configMock.mockReturnValue(0);
|
||||
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
sendMock.mockReturnValue(
|
||||
makeStream([
|
||||
{ type: GeminiEventType.Content, value: 'should not be reached' },
|
||||
]),
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const streamEnd = events.find(
|
||||
(e): e is AgentEvent<'stream_end'> => e.type === 'stream_end',
|
||||
);
|
||||
expect(streamEnd?.reason).toBe('max_turns');
|
||||
expect(streamEnd?.data).toEqual({
|
||||
code: 'MAX_TURNS_EXCEEDED',
|
||||
maxTurns: 0,
|
||||
turnCount: 0,
|
||||
});
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats GeminiClient MaxSessionTurns as a non-terminal warning event', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
sendMock.mockReturnValue(
|
||||
makeStream([
|
||||
{ type: GeminiEventType.MaxSessionTurns },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: FinishReason.STOP, usageMetadata: undefined },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const warning = events.find(
|
||||
(e): e is AgentEvent<'error'> =>
|
||||
e.type === 'error' && e._meta?.['code'] === 'MAX_TURNS_EXCEEDED',
|
||||
);
|
||||
expect(warning?.fatal).toBe(false);
|
||||
|
||||
const streamEnds = events.filter(
|
||||
(e): e is AgentEvent<'stream_end'> => e.type === 'stream_end',
|
||||
);
|
||||
const streamEnd = streamEnds[streamEnds.length - 1];
|
||||
expect(streamEnd?.reason).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('abort', () => {
|
||||
it('aborts the stream', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
// Stream that yields content then checks abort signal via a deferred
|
||||
let resolveHang: (() => void) | undefined;
|
||||
sendMock.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: GeminiEventType.Content,
|
||||
value: 'start',
|
||||
} as ServerGeminiStreamEvent;
|
||||
// Wait until externally resolved (by abort)
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveHang = resolve;
|
||||
});
|
||||
yield {
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: FinishReason.STOP, usageMetadata: undefined },
|
||||
} as ServerGeminiStreamEvent;
|
||||
})(),
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
|
||||
// Give the loop time to start processing
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// Abort and resolve the hang so the generator can finish
|
||||
await session.abort();
|
||||
resolveHang?.();
|
||||
|
||||
// Collect all events
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const streamEnd = events.find(
|
||||
(e): e is AgentEvent<'stream_end'> => e.type === 'stream_end',
|
||||
);
|
||||
expect(streamEnd?.reason).toBe('aborted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('events property', () => {
|
||||
it('accumulates all events', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
sendMock.mockReturnValue(
|
||||
makeStream([
|
||||
{ type: GeminiEventType.Content, value: 'hi' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: FinishReason.STOP, usageMetadata: undefined },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await collectEvents(session);
|
||||
|
||||
expect(session.events.length).toBeGreaterThan(0);
|
||||
expect(session.events[0]?.type).toBe('stream_start');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stream_end ordering', () => {
|
||||
it('stream_end is always the final event yielded', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
sendMock.mockReturnValue(
|
||||
makeStream([
|
||||
{ type: GeminiEventType.Content, value: 'Hello' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: FinishReason.STOP, usageMetadata: undefined },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
const events = await collectEvents(session);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[events.length - 1]?.type).toBe('stream_end');
|
||||
});
|
||||
|
||||
it('stream_end is final even after error events', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
sendMock.mockReturnValue(
|
||||
makeStream([
|
||||
{
|
||||
type: GeminiEventType.Error,
|
||||
value: { error: new Error('API error') },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
const events = await collectEvents(session);
|
||||
|
||||
expect(events[events.length - 1]?.type).toBe('stream_end');
|
||||
});
|
||||
});
|
||||
|
||||
describe('intermediate Finished events', () => {
|
||||
it('does NOT emit stream_end when tool calls are pending', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
// First turn: tool request + Finished (should NOT produce stream_end)
|
||||
sendMock.mockReturnValueOnce(
|
||||
makeStream([
|
||||
{
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: makeToolRequest('call-1', 'read_file'),
|
||||
},
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: {
|
||||
reason: FinishReason.STOP,
|
||||
usageMetadata: {
|
||||
promptTokenCount: 50,
|
||||
candidatesTokenCount: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
// Second turn: final answer
|
||||
sendMock.mockReturnValueOnce(
|
||||
makeStream([
|
||||
{ type: GeminiEventType.Content, value: 'Answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: FinishReason.STOP, usageMetadata: undefined },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const scheduleMock = deps.scheduler.schedule as ReturnType<typeof vi.fn>;
|
||||
scheduleMock.mockResolvedValueOnce([
|
||||
makeCompletedToolCall('call-1', 'read_file', 'data'),
|
||||
]);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'do it' }] });
|
||||
const events = await collectEvents(session);
|
||||
|
||||
// Only one stream_end at the very end
|
||||
const streamEnds = events.filter((e) => e.type === 'stream_end');
|
||||
expect(streamEnds).toHaveLength(1);
|
||||
expect(streamEnds[0]).toBe(events[events.length - 1]);
|
||||
});
|
||||
|
||||
it('emits usage for intermediate Finished events', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
sendMock.mockReturnValueOnce(
|
||||
makeStream([
|
||||
{
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: makeToolRequest('call-1', 'read_file'),
|
||||
},
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: {
|
||||
reason: FinishReason.STOP,
|
||||
usageMetadata: {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 30,
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
sendMock.mockReturnValueOnce(
|
||||
makeStream([
|
||||
{ type: GeminiEventType.Content, value: 'Done' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: FinishReason.STOP, usageMetadata: undefined },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const scheduleMock = deps.scheduler.schedule as ReturnType<typeof vi.fn>;
|
||||
scheduleMock.mockResolvedValueOnce([
|
||||
makeCompletedToolCall('call-1', 'read_file', 'contents'),
|
||||
]);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'go' }] });
|
||||
const events = await collectEvents(session);
|
||||
|
||||
// Should have at least one usage event from the intermediate Finished
|
||||
const usageEvents = events.filter(
|
||||
(e): e is AgentEvent<'usage'> => e.type === 'usage',
|
||||
);
|
||||
expect(usageEvents.length).toBeGreaterThanOrEqual(1);
|
||||
expect(usageEvents[0]?.inputTokens).toBe(100);
|
||||
expect(usageEvents[0]?.outputTokens).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling in runLoop', () => {
|
||||
it('catches thrown errors and emits error + stream_end', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
sendMock.mockImplementation(() => {
|
||||
throw new Error('Connection refused');
|
||||
});
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const err = events.find(
|
||||
(e): e is AgentEvent<'error'> => e.type === 'error',
|
||||
);
|
||||
expect(err?.message).toBe('Connection refused');
|
||||
expect(err?.fatal).toBe(true);
|
||||
|
||||
const streamEnd = events.find(
|
||||
(e): e is AgentEvent<'stream_end'> => e.type === 'stream_end',
|
||||
);
|
||||
expect(streamEnd?.reason).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_emitErrorAndStreamEnd metadata', () => {
|
||||
it('preserves exitCode and code in _meta for FatalError', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
// Simulate a FatalError being thrown
|
||||
const { FatalError } = await import('../utils/errors.js');
|
||||
sendMock.mockImplementation(() => {
|
||||
throw new FatalError('Disk full', 44);
|
||||
});
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const err = events.find(
|
||||
(e): e is AgentEvent<'error'> => e.type === 'error',
|
||||
);
|
||||
expect(err?.message).toBe('Disk full');
|
||||
expect(err?.fatal).toBe(true);
|
||||
expect(err?._meta?.['exitCode']).toBe(44);
|
||||
expect(err?._meta?.['errorName']).toBe('FatalError');
|
||||
});
|
||||
|
||||
it('preserves exitCode for non-FatalError errors that carry one', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
const exitCodeError = new Error('custom exit');
|
||||
(exitCodeError as Error & { exitCode: number }).exitCode = 17;
|
||||
sendMock.mockImplementation(() => {
|
||||
throw exitCodeError;
|
||||
});
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const err = events.find(
|
||||
(e): e is AgentEvent<'error'> => e.type === 'error',
|
||||
);
|
||||
expect(err?._meta?.['exitCode']).toBe(17);
|
||||
});
|
||||
|
||||
it('preserves code in _meta for errors with code property', async () => {
|
||||
const sendMock = deps.client.sendMessageStream as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
const codedError = new Error('ENOENT');
|
||||
(codedError as Error & { code: string }).code = 'ENOENT';
|
||||
sendMock.mockImplementation(() => {
|
||||
throw codedError;
|
||||
});
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const err = events.find(
|
||||
(e): e is AgentEvent<'error'> => e.type === 'error',
|
||||
);
|
||||
expect(err?._meta?.['code']).toBe('ENOENT');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,461 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview LegacyAgentSession — owns the agentic loop (send + tool
|
||||
* scheduling + multi-turn), translating all events to AgentEvents.
|
||||
*/
|
||||
|
||||
import { GeminiEventType } from '../core/turn.js';
|
||||
import type { GeminiClient } from '../core/client.js';
|
||||
import type { Scheduler } from '../scheduler/scheduler.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { ToolCallRequestInfo } from '../scheduler/types.js';
|
||||
import { ToolErrorType, isFatalToolError } from '../tools/tool-error.js';
|
||||
import { recordToolCallInteractions } from '../code_assist/telemetry.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import {
|
||||
translateEvent,
|
||||
createTranslationState,
|
||||
type TranslationState,
|
||||
} from './event-translator.js';
|
||||
import {
|
||||
geminiPartsToContentParts,
|
||||
contentPartsToGeminiParts,
|
||||
toolResultDisplayToContentParts,
|
||||
buildToolResponseData,
|
||||
} from './content-utils.js';
|
||||
import type {
|
||||
AgentEvent,
|
||||
AgentSession,
|
||||
AgentSend,
|
||||
ContentPart,
|
||||
StreamEndReason,
|
||||
} from './types.js';
|
||||
|
||||
export interface LegacySessionDeps {
|
||||
client: GeminiClient;
|
||||
scheduler: Scheduler;
|
||||
config: Config;
|
||||
promptId: string;
|
||||
streamId?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LegacyAgentSession
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class LegacyAgentSession implements AgentSession {
|
||||
private _events: AgentEvent[] = [];
|
||||
private _translationState: TranslationState;
|
||||
private _subscribers: Set<() => void> = new Set();
|
||||
private _streamDone: boolean = false;
|
||||
private _streamEndEmitted: boolean = false;
|
||||
private _abortController: AbortController = new AbortController();
|
||||
|
||||
private readonly _client: GeminiClient;
|
||||
private readonly _scheduler: Scheduler;
|
||||
private readonly _config: Config;
|
||||
private readonly _promptId: string;
|
||||
|
||||
constructor(deps: LegacySessionDeps) {
|
||||
this._translationState = createTranslationState(deps.streamId);
|
||||
this._client = deps.client;
|
||||
this._scheduler = deps.scheduler;
|
||||
this._config = deps.config;
|
||||
this._promptId = deps.promptId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentSession interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async send(payload: AgentSend): Promise<{ streamId: string }> {
|
||||
const message = 'message' in payload ? payload.message : undefined;
|
||||
if (!message) {
|
||||
throw new Error('LegacyAgentSession.send() only supports message sends.');
|
||||
}
|
||||
|
||||
const parts = contentPartsToGeminiParts(message);
|
||||
|
||||
// Start the loop in the background — don't await
|
||||
this._runLoop(parts).catch((err: unknown) => {
|
||||
this._emitErrorAndStreamEnd(err);
|
||||
});
|
||||
|
||||
return { streamId: this._translationState.streamId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an async iterator that replays existing events, then live-follows
|
||||
* new events as they arrive. Terminates after yielding a stream_end event,
|
||||
* consistent with MockAgentSession behavior.
|
||||
*/
|
||||
async *stream(options?: {
|
||||
streamId?: string;
|
||||
eventId?: string;
|
||||
}): AsyncIterableIterator<AgentEvent> {
|
||||
let startIndex = 0;
|
||||
|
||||
if (options?.eventId) {
|
||||
const idx = this._events.findIndex((e) => e.id === options.eventId);
|
||||
if (idx !== -1) {
|
||||
startIndex = idx + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Replay existing events
|
||||
for (let i = startIndex; i < this._events.length; i++) {
|
||||
const event = this._events[i];
|
||||
if (event) {
|
||||
yield event;
|
||||
if (event.type === 'stream_end') return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._streamDone) return;
|
||||
|
||||
// Live-follow new events. Drain any buffered events after each wake-up,
|
||||
// even if _streamDone was set between the notification and resumption.
|
||||
let replayedUpTo = this._events.length;
|
||||
while (true) {
|
||||
// Wait for new events or stream completion
|
||||
if (replayedUpTo >= this._events.length && !this._streamDone) {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (this._events.length > replayedUpTo || this._streamDone) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const handler = (): void => {
|
||||
this._subscribers.delete(handler);
|
||||
resolve();
|
||||
};
|
||||
this._subscribers.add(handler);
|
||||
});
|
||||
}
|
||||
|
||||
// Always drain buffered events before checking _streamDone
|
||||
while (replayedUpTo < this._events.length) {
|
||||
const event = this._events[replayedUpTo];
|
||||
replayedUpTo++;
|
||||
if (event) {
|
||||
yield event;
|
||||
if (event.type === 'stream_end') return;
|
||||
}
|
||||
}
|
||||
|
||||
// Exit only after draining
|
||||
if (this._streamDone) return;
|
||||
}
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
this._abortController.abort();
|
||||
}
|
||||
|
||||
get events(): AgentEvent[] {
|
||||
return this._events;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core: agentic loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async _runLoop(initialParts: Part[]): Promise<void> {
|
||||
let currentParts: Part[] = initialParts;
|
||||
let turnCount = 0;
|
||||
const maxTurns = this._config.getMaxSessionTurns();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
turnCount++;
|
||||
if (maxTurns >= 0 && turnCount > maxTurns) {
|
||||
this._ensureStreamStart();
|
||||
this._appendAndNotify([
|
||||
this._makeInternalEvent('stream_end', {
|
||||
streamId: this._translationState.streamId,
|
||||
reason: 'max_turns',
|
||||
data: {
|
||||
code: 'MAX_TURNS_EXCEEDED',
|
||||
maxTurns,
|
||||
turnCount: turnCount - 1,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
this._markStreamDone();
|
||||
return;
|
||||
}
|
||||
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
|
||||
const responseStream = this._client.sendMessageStream(
|
||||
currentParts,
|
||||
this._abortController.signal,
|
||||
this._promptId,
|
||||
);
|
||||
|
||||
// Process the stream — translate events and collect tool requests
|
||||
for await (const event of responseStream) {
|
||||
if (this._abortController.signal.aborted) {
|
||||
this._ensureStreamStart();
|
||||
this._appendAndNotify([
|
||||
this._makeInternalEvent('stream_end', {
|
||||
streamId: this._translationState.streamId,
|
||||
reason: 'aborted',
|
||||
}),
|
||||
]);
|
||||
this._markStreamDone();
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect tool call requests BEFORE translating so we can
|
||||
// decide whether to suppress the Finished event's stream_end.
|
||||
if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
toolCallRequests.push(event.value);
|
||||
}
|
||||
|
||||
// Translate to AgentEvents
|
||||
const agentEvents = translateEvent(event, this._translationState);
|
||||
|
||||
// Finished events don't mean the session is done — if there are
|
||||
// pending tool calls, more turns are coming. Suppress stream_end
|
||||
// from the Finished event in that case (keep usage events).
|
||||
if (
|
||||
event.type === GeminiEventType.Finished &&
|
||||
toolCallRequests.length > 0
|
||||
) {
|
||||
const filtered = agentEvents.filter((e) => e.type !== 'stream_end');
|
||||
this._appendAndNotify(filtered);
|
||||
} else {
|
||||
this._appendAndNotify(agentEvents);
|
||||
}
|
||||
|
||||
// Error events → abort the loop
|
||||
if (event.type === GeminiEventType.Error) {
|
||||
this._ensureStreamEnd('failed');
|
||||
this._markStreamDone();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fatal error events that translator doesn't emit stream_end for
|
||||
if (
|
||||
event.type === GeminiEventType.InvalidStream ||
|
||||
event.type === GeminiEventType.ContextWindowWillOverflow
|
||||
) {
|
||||
this._ensureStreamEnd('failed');
|
||||
this._markStreamDone();
|
||||
return;
|
||||
}
|
||||
|
||||
// Terminal events — translator already emitted stream_end
|
||||
if (
|
||||
event.type === GeminiEventType.AgentExecutionStopped ||
|
||||
event.type === GeminiEventType.UserCancelled
|
||||
) {
|
||||
this._markStreamDone();
|
||||
return;
|
||||
}
|
||||
// LoopDetected is NOT terminal — the stream continues.
|
||||
// Consumer handles it (warning in non-interactive, dialog in interactive).
|
||||
}
|
||||
|
||||
if (toolCallRequests.length === 0) {
|
||||
this._ensureStreamEnd('completed');
|
||||
this._markStreamDone();
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule tool calls
|
||||
const completedToolCalls = await this._scheduler.schedule(
|
||||
toolCallRequests,
|
||||
this._abortController.signal,
|
||||
);
|
||||
|
||||
// Emit tool_response AgentEvents for each completed tool call
|
||||
const toolResponseParts: Part[] = [];
|
||||
for (const tc of completedToolCalls) {
|
||||
const response = tc.response;
|
||||
const request = tc.request;
|
||||
|
||||
const content: ContentPart[] = response.error
|
||||
? [{ type: 'text', text: response.error.message }]
|
||||
: geminiPartsToContentParts(response.responseParts);
|
||||
const displayContent = toolResultDisplayToContentParts(
|
||||
response.resultDisplay,
|
||||
);
|
||||
const data = buildToolResponseData(response);
|
||||
|
||||
this._appendAndNotify([
|
||||
this._makeInternalEvent('tool_response', {
|
||||
requestId: request.callId,
|
||||
name: request.name,
|
||||
content,
|
||||
isError: response.error !== undefined,
|
||||
...(displayContent ? { displayContent } : {}),
|
||||
...(data ? { data } : {}),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (response.responseParts) {
|
||||
toolResponseParts.push(...response.responseParts);
|
||||
}
|
||||
}
|
||||
|
||||
// Record tool calls in chat history
|
||||
try {
|
||||
const currentModel =
|
||||
this._client.getCurrentSequenceModel() ?? this._config.getModel();
|
||||
this._client
|
||||
.getChat()
|
||||
.recordCompletedToolCalls(currentModel, completedToolCalls);
|
||||
|
||||
await recordToolCallInteractions(this._config, completedToolCalls);
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Error recording completed tool call information: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if a tool requested stop execution
|
||||
const stopTool = completedToolCalls.find(
|
||||
(tc) =>
|
||||
tc.response.errorType === ToolErrorType.STOP_EXECUTION &&
|
||||
tc.response.error !== undefined,
|
||||
);
|
||||
if (stopTool) {
|
||||
this._ensureStreamEnd('completed');
|
||||
this._markStreamDone();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for fatal tool errors
|
||||
const fatalTool = completedToolCalls.find((tc) =>
|
||||
isFatalToolError(tc.response.errorType),
|
||||
);
|
||||
if (fatalTool) {
|
||||
const msg = fatalTool.response.error?.message ?? 'Fatal tool error';
|
||||
this._appendAndNotify([
|
||||
this._makeInternalEvent('error', {
|
||||
status: 'INTERNAL',
|
||||
message: `Fatal tool error (${fatalTool.request.name}): ${msg}`,
|
||||
fatal: true,
|
||||
}),
|
||||
]);
|
||||
this._ensureStreamEnd('failed');
|
||||
this._markStreamDone();
|
||||
return;
|
||||
}
|
||||
|
||||
// Feed tool results back for next turn
|
||||
currentParts = toolResponseParts;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
this._emitErrorAndStreamEnd(err);
|
||||
this._markStreamDone();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Sets _streamDone and notifies subscribers so the stream iterator can exit. */
|
||||
private _markStreamDone(): void {
|
||||
this._streamDone = true;
|
||||
this._notifySubscribers();
|
||||
}
|
||||
|
||||
private _appendAndNotify(events: AgentEvent[]): void {
|
||||
for (const event of events) {
|
||||
this._events.push(event);
|
||||
if (event.type === 'stream_end') {
|
||||
this._streamEndEmitted = true;
|
||||
}
|
||||
}
|
||||
if (events.length > 0) {
|
||||
this._notifySubscribers();
|
||||
}
|
||||
}
|
||||
|
||||
private _notifySubscribers(): void {
|
||||
for (const handler of this._subscribers) {
|
||||
handler();
|
||||
}
|
||||
}
|
||||
|
||||
private _ensureStreamStart(): void {
|
||||
if (!this._translationState.streamStartEmitted) {
|
||||
const startEvent = this._makeInternalEvent('stream_start', {
|
||||
streamId: this._translationState.streamId,
|
||||
});
|
||||
this._events.push(startEvent);
|
||||
this._translationState.streamStartEmitted = true;
|
||||
this._notifySubscribers();
|
||||
}
|
||||
}
|
||||
|
||||
private _ensureStreamEnd(reason: StreamEndReason = 'completed'): void {
|
||||
if (!this._streamEndEmitted && this._translationState.streamStartEmitted) {
|
||||
this._streamEndEmitted = true;
|
||||
const endEvent = this._makeInternalEvent('stream_end', {
|
||||
streamId: this._translationState.streamId,
|
||||
reason,
|
||||
});
|
||||
this._events.push(endEvent);
|
||||
this._notifySubscribers();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Review fix #4: Preserves error metadata (name, exitCode, stack) in _meta
|
||||
* so downstream consumers can reconstruct proper error types.
|
||||
*/
|
||||
private _emitErrorAndStreamEnd(err: unknown): void {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
this._ensureStreamStart();
|
||||
|
||||
const meta: Record<string, unknown> = {};
|
||||
if (err instanceof Error) {
|
||||
meta['errorName'] = err.constructor.name;
|
||||
if ('exitCode' in err && typeof err.exitCode === 'number') {
|
||||
meta['exitCode'] = err.exitCode;
|
||||
}
|
||||
if ('code' in err) {
|
||||
meta['code'] = err.code;
|
||||
}
|
||||
}
|
||||
|
||||
const errorEvent = this._makeInternalEvent('error', {
|
||||
status: 'INTERNAL' as const,
|
||||
message,
|
||||
fatal: true,
|
||||
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
|
||||
});
|
||||
this._events.push(errorEvent);
|
||||
|
||||
this._ensureStreamEnd('failed');
|
||||
this._notifySubscribers();
|
||||
}
|
||||
|
||||
private _makeInternalEvent(
|
||||
type: AgentEvent['type'],
|
||||
payload: Partial<AgentEvent>,
|
||||
): AgentEvent {
|
||||
const id = `${this._translationState.streamId}-${this._translationState.eventCounter++}`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- constructing AgentEvent from common fields + payload
|
||||
return {
|
||||
...payload,
|
||||
id,
|
||||
timestamp: new Date().toISOString(),
|
||||
streamId: this._translationState.streamId,
|
||||
type,
|
||||
} as AgentEvent;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export Part type alias for internal use (avoids importing @google/genai directly)
|
||||
type Part = import('@google/genai').Part;
|
||||
@@ -79,16 +79,9 @@ export type AgentEventData<
|
||||
EventType extends keyof AgentEvents = keyof AgentEvents,
|
||||
> = AgentEvents[EventType] & { type: EventType };
|
||||
|
||||
/**
|
||||
* Mapped type that produces a proper discriminated union when `EventType` is
|
||||
* the default (all keys), enabling `switch (event.type)` narrowing.
|
||||
* When a specific EventType is provided, resolves to a single variant.
|
||||
*/
|
||||
export type AgentEvent<
|
||||
EventType extends keyof AgentEvents = keyof AgentEvents,
|
||||
> = {
|
||||
[K in EventType]: AgentEventCommon & AgentEvents[K] & { type: K };
|
||||
}[EventType];
|
||||
> = AgentEventCommon & AgentEventData<EventType>;
|
||||
|
||||
export interface AgentEvents {
|
||||
/** MUST be the first event emitted in a session. */
|
||||
@@ -268,7 +261,7 @@ export interface StreamStart {
|
||||
streamId: string;
|
||||
}
|
||||
|
||||
export type StreamEndReason =
|
||||
type StreamEndReason =
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'aborted'
|
||||
|
||||
@@ -44,6 +44,11 @@ vi.mock('../../utils/debugLogger.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock browser consent to always grant consent by default
|
||||
vi.mock('../../utils/browserConsent.js', () => ({
|
||||
getBrowserConsentIfNeeded: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock('./automationOverlay.js', () => ({
|
||||
injectAutomationOverlay: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -64,6 +69,7 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
import * as fs from 'node:fs';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { getBrowserConsentIfNeeded } from '../../utils/browserConsent.js';
|
||||
|
||||
describe('BrowserManager', () => {
|
||||
let mockConfig: Config;
|
||||
@@ -72,6 +78,9 @@ describe('BrowserManager', () => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(injectAutomationOverlay).mockClear();
|
||||
|
||||
// Re-establish consent mock after resetAllMocks
|
||||
vi.mocked(getBrowserConsentIfNeeded).mockResolvedValue(true);
|
||||
|
||||
// Setup mock config
|
||||
mockConfig = makeFakeConfig({
|
||||
agents: {
|
||||
@@ -527,6 +536,41 @@ describe('BrowserManager', () => {
|
||||
/sessionMode: persistent/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass --no-usage-statistics and --no-performance-crux when privacy is disabled', async () => {
|
||||
const privacyDisabledConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
headless: false,
|
||||
},
|
||||
},
|
||||
usageStatisticsEnabled: false,
|
||||
});
|
||||
|
||||
const manager = new BrowserManager(privacyDisabledConfig);
|
||||
await manager.ensureConnection();
|
||||
|
||||
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
|
||||
?.args as string[];
|
||||
expect(args).toContain('--no-usage-statistics');
|
||||
expect(args).toContain('--no-performance-crux');
|
||||
});
|
||||
|
||||
it('should NOT pass privacy flags when usage statistics are enabled', async () => {
|
||||
// Default config has usageStatisticsEnabled: true (or undefined)
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
await manager.ensureConnection();
|
||||
|
||||
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
|
||||
?.args as string[];
|
||||
expect(args).not.toContain('--no-usage-statistics');
|
||||
expect(args).not.toContain('--no-performance-crux');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP isolation', () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import { Storage } from '../../config/storage.js';
|
||||
import { getBrowserConsentIfNeeded } from '../../utils/browserConsent.js';
|
||||
import { injectInputBlocker } from './inputBlocker.js';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
@@ -260,6 +261,16 @@ export class BrowserManager {
|
||||
if (this.rawMcpClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Request browser consent if needed (first-run privacy notice)
|
||||
const consentGranted = await getBrowserConsentIfNeeded();
|
||||
if (!consentGranted) {
|
||||
throw new Error(
|
||||
'Browser agent requires user consent to proceed. ' +
|
||||
'Please re-run and accept the privacy notice.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.connectMcp();
|
||||
}
|
||||
|
||||
@@ -352,6 +363,11 @@ export class BrowserManager {
|
||||
mcpArgs.push('--userDataDir', defaultProfilePath);
|
||||
}
|
||||
|
||||
// Respect the user's privacy.usageStatisticsEnabled setting
|
||||
if (!this.config.getUsageStatisticsEnabled()) {
|
||||
mcpArgs.push('--no-usage-statistics', '--no-performance-crux');
|
||||
}
|
||||
|
||||
if (
|
||||
browserConfig.customConfig.allowedDomains &&
|
||||
browserConfig.customConfig.allowedDomains.length > 0
|
||||
|
||||
@@ -91,9 +91,18 @@ import {
|
||||
type LocalAgentDefinition,
|
||||
type SubagentActivityEvent,
|
||||
type OutputConfig,
|
||||
SubagentActivityErrorType,
|
||||
} from './types.js';
|
||||
import type { AnyDeclarativeTool, AnyToolInvocation } from '../tools/tools.js';
|
||||
import type { ToolCallRequestInfo } from '../scheduler/types.js';
|
||||
import {
|
||||
ToolConfirmationOutcome,
|
||||
type AnyDeclarativeTool,
|
||||
type AnyToolInvocation,
|
||||
} from '../tools/tools.js';
|
||||
import {
|
||||
type ToolCallRequestInfo,
|
||||
CoreToolCallStatus,
|
||||
} from '../scheduler/types.js';
|
||||
|
||||
import { CompressionStatus } from '../core/turn.js';
|
||||
import { ChatCompressionService } from '../services/chatCompressionService.js';
|
||||
import type {
|
||||
@@ -365,6 +374,76 @@ describe('LocalAgentExecutor', () => {
|
||||
});
|
||||
|
||||
describe('create (Initialization and Validation)', () => {
|
||||
it('should explicitly map execution context properties to prevent unintended propagation', async () => {
|
||||
const definition = createTestDefinition([LS_TOOL_NAME]);
|
||||
const mockGeminiClient =
|
||||
{} as unknown as import('../core/client.js').GeminiClient;
|
||||
const mockSandboxManager =
|
||||
{} as unknown as import('../services/sandboxManager.js').SandboxManager;
|
||||
const extendedContext = {
|
||||
config: mockConfig,
|
||||
promptId: mockConfig.promptId,
|
||||
toolRegistry: parentToolRegistry,
|
||||
promptRegistry: mockConfig.promptRegistry,
|
||||
resourceRegistry: mockConfig.resourceRegistry,
|
||||
messageBus: mockConfig.messageBus,
|
||||
geminiClient: mockGeminiClient,
|
||||
sandboxManager: mockSandboxManager,
|
||||
unintendedProperty: 'should not be here',
|
||||
} as unknown as import('../config/agent-loop-context.js').AgentLoopContext;
|
||||
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
extendedContext,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
mockModelResponse([
|
||||
{
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
args: { finalResult: 'done' },
|
||||
id: 'call1',
|
||||
},
|
||||
]);
|
||||
|
||||
await executor.run({ goal: 'test' }, signal);
|
||||
|
||||
const chatConstructorArgs = MockedGeminiChat.mock.calls[0];
|
||||
const executionContext = chatConstructorArgs[0];
|
||||
|
||||
expect(executionContext).toBeDefined();
|
||||
expect(executionContext.config).toBe(extendedContext.config);
|
||||
expect(executionContext.promptId).toBe(extendedContext.promptId);
|
||||
expect(executionContext.geminiClient).toBe(extendedContext.geminiClient);
|
||||
expect(executionContext.sandboxManager).toBe(
|
||||
extendedContext.sandboxManager,
|
||||
);
|
||||
|
||||
const agentToolRegistry = executor['toolRegistry'];
|
||||
const agentPromptRegistry = executor['promptRegistry'];
|
||||
const agentResourceRegistry = executor['resourceRegistry'];
|
||||
|
||||
expect(executionContext.toolRegistry).toBe(agentToolRegistry);
|
||||
expect(executionContext.promptRegistry).toBe(agentPromptRegistry);
|
||||
expect(executionContext.resourceRegistry).toBe(agentResourceRegistry);
|
||||
|
||||
expect(executionContext.messageBus).toBe(
|
||||
agentToolRegistry.getMessageBus(),
|
||||
);
|
||||
|
||||
// Ensure the unintended property was not spread
|
||||
expect(
|
||||
(executionContext as unknown as { unintendedProperty?: string })
|
||||
.unintendedProperty,
|
||||
).toBeUndefined();
|
||||
|
||||
// Ensure registries and message bus are not the parent's
|
||||
expect(executionContext.toolRegistry).not.toBe(
|
||||
extendedContext.toolRegistry,
|
||||
);
|
||||
expect(executionContext.messageBus).not.toBe(extendedContext.messageBus);
|
||||
});
|
||||
|
||||
it('should create successfully with allowed tools', async () => {
|
||||
const definition = createTestDefinition([LS_TOOL_NAME]);
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
@@ -943,6 +1022,7 @@ describe('LocalAgentExecutor', () => {
|
||||
data: expect.objectContaining({
|
||||
context: 'protocol_violation',
|
||||
error: expectedError,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -988,6 +1068,7 @@ describe('LocalAgentExecutor', () => {
|
||||
context: 'tool_call',
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
error: expectedError,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1091,7 +1172,7 @@ describe('LocalAgentExecutor', () => {
|
||||
if (callsStarted === 2) resolveCalls();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
return {
|
||||
status: 'success',
|
||||
status: CoreToolCallStatus.Success,
|
||||
request: reqInfo,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
@@ -1109,7 +1190,7 @@ describe('LocalAgentExecutor', () => {
|
||||
],
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
contentLength: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
@@ -1147,10 +1228,10 @@ describe('LocalAgentExecutor', () => {
|
||||
expect(parts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
functionResponse: expect.objectContaining({ id: 'c1' }),
|
||||
functionResponse: expect.objectContaining({ name: LS_TOOL_NAME }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionResponse: expect.objectContaining({ id: 'c2' }),
|
||||
functionResponse: expect.objectContaining({ name: LS_TOOL_NAME }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
@@ -1221,6 +1302,7 @@ describe('LocalAgentExecutor', () => {
|
||||
data: expect.objectContaining({
|
||||
context: 'tool_call_unauthorized',
|
||||
name: READ_FILE_TOOL_NAME,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1274,6 +1356,7 @@ describe('LocalAgentExecutor', () => {
|
||||
context: 'tool_call',
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
error: expect.stringContaining('Output validation failed'),
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1320,6 +1403,7 @@ describe('LocalAgentExecutor', () => {
|
||||
type: 'ERROR',
|
||||
data: expect.objectContaining({
|
||||
error: `Error: Failed to create chat object: ${getErrorMessage(initError)}`,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1348,7 +1432,7 @@ describe('LocalAgentExecutor', () => {
|
||||
]);
|
||||
mockScheduleAgentTools.mockResolvedValueOnce([
|
||||
{
|
||||
status: 'error',
|
||||
status: CoreToolCallStatus.Error,
|
||||
request: {
|
||||
callId: 'call1',
|
||||
name: LS_TOOL_NAME,
|
||||
@@ -1399,6 +1483,7 @@ describe('LocalAgentExecutor', () => {
|
||||
context: 'tool_call',
|
||||
name: LS_TOOL_NAME,
|
||||
error: toolErrorMessage,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1421,6 +1506,157 @@ describe('LocalAgentExecutor', () => {
|
||||
expect(output.terminate_reason).toBe(AgentTerminateMode.GOAL);
|
||||
expect(output.result).toBe('Aborted due to tool failure.');
|
||||
});
|
||||
|
||||
it('should handle a soft tool rejection (outcome: Cancel) and provide direct instructions to the model', async () => {
|
||||
const definition = createTestDefinition([LS_TOOL_NAME]);
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
// Turn 1: Model calls a tool that will be rejected
|
||||
mockModelResponse([
|
||||
{ name: LS_TOOL_NAME, args: { path: '/secret' }, id: 'call1' },
|
||||
]);
|
||||
mockScheduleAgentTools.mockResolvedValueOnce([
|
||||
{
|
||||
status: 'cancelled',
|
||||
request: {
|
||||
callId: 'call1',
|
||||
name: LS_TOOL_NAME,
|
||||
args: { path: '/secret' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'test-prompt',
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
outcome: ToolConfirmationOutcome.Cancel, // Soft rejection
|
||||
response: {
|
||||
callId: 'call1',
|
||||
resultDisplay: '',
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: LS_TOOL_NAME,
|
||||
response: {
|
||||
error:
|
||||
'[Operation Cancelled] Reason: User denied execution.',
|
||||
},
|
||||
id: 'call1',
|
||||
},
|
||||
},
|
||||
],
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Turn 2: Model sees the rejection + consolidated instructions and completes
|
||||
mockModelResponse([
|
||||
{
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
args: { finalResult: 'User rejected access to /secret.' },
|
||||
id: 'call2',
|
||||
},
|
||||
]);
|
||||
|
||||
const output = await executor.run(
|
||||
{ goal: 'Soft rejection test' },
|
||||
signal,
|
||||
);
|
||||
|
||||
// Verify the activity stream reported the consolidated instruction
|
||||
expect(activities).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'ERROR',
|
||||
data: expect.objectContaining({
|
||||
context: 'tool_call',
|
||||
name: LS_TOOL_NAME,
|
||||
error: expect.stringContaining('User rejected this operation'),
|
||||
errorType: SubagentActivityErrorType.REJECTED,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify the instruction was sent back to the model as the tool error
|
||||
const turn2Params = getMockMessageParams(1);
|
||||
const parts = turn2Params.message as Part[];
|
||||
const errorMsg = parts[0].functionResponse?.response?.['error'];
|
||||
expect(typeof errorMsg).toBe('string');
|
||||
if (typeof errorMsg === 'string') {
|
||||
expect(errorMsg).toContain('User rejected this operation');
|
||||
expect(errorMsg).toContain('acknowledge this, rethink your strategy');
|
||||
}
|
||||
|
||||
expect(output.terminate_reason).toBe(AgentTerminateMode.GOAL);
|
||||
expect(output.result).toBe('User rejected access to /secret.');
|
||||
});
|
||||
|
||||
it('should handle a hard tool abort (cancelled with no outcome) and terminate the agent', async () => {
|
||||
const definition = createTestDefinition([LS_TOOL_NAME]);
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
// Turn 1: Model calls a tool that will be aborted (e.g. Ctrl+C)
|
||||
mockModelResponse([
|
||||
{ name: LS_TOOL_NAME, args: { path: '/secret' }, id: 'call1' },
|
||||
]);
|
||||
mockScheduleAgentTools.mockResolvedValueOnce([
|
||||
{
|
||||
status: 'cancelled',
|
||||
request: {
|
||||
callId: 'call1',
|
||||
name: LS_TOOL_NAME,
|
||||
args: { path: '/secret' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'test-prompt',
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
outcome: undefined, // Hard abort
|
||||
response: {
|
||||
callId: 'call1',
|
||||
resultDisplay: '',
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: LS_TOOL_NAME,
|
||||
response: { error: 'Request cancelled.' },
|
||||
id: 'call1',
|
||||
},
|
||||
},
|
||||
],
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const output = await executor.run({ goal: 'Hard abort test' }, signal);
|
||||
|
||||
// Verify the activity stream reported the cancellation
|
||||
expect(activities).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'ERROR',
|
||||
data: expect.objectContaining({
|
||||
context: 'tool_call',
|
||||
name: LS_TOOL_NAME,
|
||||
error: 'Request cancelled.',
|
||||
errorType: SubagentActivityErrorType.CANCELLED,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Agent should terminate with ABORTED status
|
||||
expect(output.terminate_reason).toBe(AgentTerminateMode.ABORTED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Model Routing', () => {
|
||||
@@ -1615,6 +1851,7 @@ describe('LocalAgentExecutor', () => {
|
||||
data: expect.objectContaining({
|
||||
context: 'timeout',
|
||||
error: 'Agent timed out after 0.5 minutes.',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1803,6 +2040,7 @@ describe('LocalAgentExecutor', () => {
|
||||
data: expect.objectContaining({
|
||||
context: 'recovery_turn',
|
||||
error: 'Graceful recovery attempt failed. Reason: stop',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1886,6 +2124,7 @@ describe('LocalAgentExecutor', () => {
|
||||
data: expect.objectContaining({
|
||||
context: 'recovery_turn',
|
||||
error: 'Graceful recovery attempt failed. Reason: stop',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -2007,6 +2246,7 @@ describe('LocalAgentExecutor', () => {
|
||||
data: expect.objectContaining({
|
||||
context: 'recovery_turn',
|
||||
error: 'Graceful recovery attempt failed. Reason: stop',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import { ResourceRegistry } from '../resources/resource-registry.js';
|
||||
import { type AnyDeclarativeTool } from '../tools/tools.js';
|
||||
import {
|
||||
type AnyDeclarativeTool,
|
||||
ToolConfirmationOutcome,
|
||||
} from '../tools/tools.js';
|
||||
import {
|
||||
DiscoveredMCPTool,
|
||||
isMcpToolName,
|
||||
@@ -46,6 +49,9 @@ import {
|
||||
DEFAULT_QUERY_STRING,
|
||||
DEFAULT_MAX_TURNS,
|
||||
DEFAULT_MAX_TIME_MINUTES,
|
||||
SubagentActivityErrorType,
|
||||
SUBAGENT_REJECTED_ERROR_PREFIX,
|
||||
SUBAGENT_CANCELLED_ERROR_MESSAGE,
|
||||
type LocalAgentDefinition,
|
||||
type AgentInputs,
|
||||
type OutputObject,
|
||||
@@ -113,7 +119,10 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
|
||||
private get executionContext(): AgentLoopContext {
|
||||
return {
|
||||
...this.context,
|
||||
config: this.context.config,
|
||||
promptId: this.context.promptId,
|
||||
geminiClient: this.context.geminiClient,
|
||||
sandboxManager: this.context.sandboxManager,
|
||||
toolRegistry: this.toolRegistry,
|
||||
promptRegistry: this.promptRegistry,
|
||||
resourceRegistry: this.resourceRegistry,
|
||||
@@ -335,6 +344,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
this.emitActivity('ERROR', {
|
||||
error: `Agent stopped calling tools but did not call '${TASK_COMPLETE_TOOL_NAME}' to finalize the session.`,
|
||||
context: 'protocol_violation',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
return {
|
||||
status: 'stop',
|
||||
@@ -468,6 +478,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
this.emitActivity('ERROR', {
|
||||
error: `Graceful recovery attempt failed. Reason: ${turnResult.status}`,
|
||||
context: 'recovery_turn',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
return null;
|
||||
} catch (error) {
|
||||
@@ -475,6 +486,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
this.emitActivity('ERROR', {
|
||||
error: `Graceful recovery attempt failed: ${String(error)}`,
|
||||
context: 'recovery_turn',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
@@ -680,12 +692,14 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
this.emitActivity('ERROR', {
|
||||
error: finalResult,
|
||||
context: 'timeout',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
} else if (terminateReason === AgentTerminateMode.MAX_TURNS) {
|
||||
finalResult = `Agent reached max turns limit (${maxTurns}).`;
|
||||
this.emitActivity('ERROR', {
|
||||
error: finalResult,
|
||||
context: 'max_turns',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
} else if (
|
||||
terminateReason === AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL
|
||||
@@ -697,6 +711,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
this.emitActivity('ERROR', {
|
||||
error: finalResult,
|
||||
context: 'protocol_violation',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -751,6 +766,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
this.emitActivity('ERROR', {
|
||||
error: finalResult,
|
||||
context: 'timeout',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
return {
|
||||
result: finalResult,
|
||||
@@ -758,7 +774,10 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
};
|
||||
}
|
||||
|
||||
this.emitActivity('ERROR', { error: String(error) });
|
||||
this.emitActivity('ERROR', {
|
||||
error: String(error),
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
throw error; // Re-throw other errors or external aborts.
|
||||
} finally {
|
||||
deadlineTimer.abort();
|
||||
@@ -1027,6 +1046,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
context: 'tool_call',
|
||||
name: toolName,
|
||||
error,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -1054,6 +1074,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
context: 'tool_call',
|
||||
name: toolName,
|
||||
error,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -1096,6 +1117,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
name: toolName,
|
||||
callId,
|
||||
error,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -1139,6 +1161,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
name: toolName,
|
||||
callId,
|
||||
error,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1163,6 +1186,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
name: toolName,
|
||||
callId,
|
||||
error,
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
|
||||
continue;
|
||||
@@ -1210,18 +1234,46 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
name: toolName,
|
||||
callId: call.request.callId,
|
||||
error: call.response.error?.message || 'Unknown error',
|
||||
errorType: SubagentActivityErrorType.GENERIC,
|
||||
});
|
||||
} else if (call.status === 'cancelled') {
|
||||
this.emitActivity('ERROR', {
|
||||
context: 'tool_call',
|
||||
name: toolName,
|
||||
callId: call.request.callId,
|
||||
error: 'Request cancelled.',
|
||||
});
|
||||
aborted = true;
|
||||
const isSoftRejection =
|
||||
call.outcome === ToolConfirmationOutcome.Cancel;
|
||||
|
||||
if (isSoftRejection) {
|
||||
const error = `${SUBAGENT_REJECTED_ERROR_PREFIX} Please acknowledge this, rethink your strategy, and try a different approach. If you cannot proceed without the rejected operation, summarize the issue and use \`${TASK_COMPLETE_TOOL_NAME}\` to report your findings and the blocker.`;
|
||||
this.emitActivity('ERROR', {
|
||||
context: 'tool_call',
|
||||
name: toolName,
|
||||
callId: call.request.callId,
|
||||
error,
|
||||
errorType: SubagentActivityErrorType.REJECTED,
|
||||
});
|
||||
// Soft rejection: we do NOT set aborted=true, allowing the agent to rethink.
|
||||
|
||||
// Provide the direct instruction to the model as the tool error response.
|
||||
syncResults.set(call.request.callId, {
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: call.request.callId,
|
||||
response: { error },
|
||||
},
|
||||
});
|
||||
continue; // Skip the generic syncResults.set below
|
||||
} else {
|
||||
// Hard abort (Ctrl+C)
|
||||
this.emitActivity('ERROR', {
|
||||
context: 'tool_call',
|
||||
name: toolName,
|
||||
callId: call.request.callId,
|
||||
error: SUBAGENT_CANCELLED_ERROR_MESSAGE,
|
||||
errorType: SubagentActivityErrorType.CANCELLED,
|
||||
});
|
||||
aborted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Add result to syncResults to preserve order later
|
||||
// Add result to syncResults for other statuses (success, error, hard abort)
|
||||
syncResults.set(call.request.callId, call.response.responseParts[0]);
|
||||
}
|
||||
}
|
||||
@@ -1332,7 +1384,8 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
Important Rules:
|
||||
* You are running in a non-interactive mode. You CANNOT ask the user for input or clarification.
|
||||
* Work systematically using available tools to complete your task.
|
||||
* Always use absolute paths for file operations. Construct them using the provided "Environment Context".`;
|
||||
* Always use absolute paths for file operations. Construct them using the provided "Environment Context".
|
||||
* If a tool call is rejected by the user, acknowledge the rejection, rethink your strategy, and try a different approach. Do not repeatedly attempt the same rejected operation.`;
|
||||
|
||||
if (this.definition.outputConfig) {
|
||||
finalPrompt += `
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
type SubagentActivityEvent,
|
||||
type AgentInputs,
|
||||
type SubagentProgress,
|
||||
SubagentActivityErrorType,
|
||||
SUBAGENT_REJECTED_ERROR_PREFIX,
|
||||
} from './types.js';
|
||||
import { LocalSubagentInvocation } from './local-invocation.js';
|
||||
import { LocalAgentExecutor } from './local-executor.js';
|
||||
@@ -230,7 +232,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
expect(display.terminateReason).toBe(AgentTerminateMode.TIMEOUT);
|
||||
});
|
||||
|
||||
it('should stream THOUGHT_CHUNK activities from the executor', async () => {
|
||||
it('should stream THOUGHT_CHUNK activities from the executor, replacing the last running thought', async () => {
|
||||
mockExecutorInstance.run.mockImplementation(async () => {
|
||||
const onActivity = MockLocalAgentExecutor.create.mock.calls[0][2];
|
||||
|
||||
@@ -245,7 +247,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'THOUGHT_CHUNK',
|
||||
data: { text: ' Still thinking.' },
|
||||
data: { text: 'Thinking about next steps.' },
|
||||
} as SubagentActivityEvent);
|
||||
}
|
||||
return { result: 'Done', terminate_reason: AgentTerminateMode.GOAL };
|
||||
@@ -258,7 +260,13 @@ describe('LocalSubagentInvocation', () => {
|
||||
expect(lastCall.recentActivity).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'thought',
|
||||
content: 'Analyzing... Still thinking.',
|
||||
content: 'Thinking about next steps.',
|
||||
}),
|
||||
);
|
||||
expect(lastCall.recentActivity).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'thought',
|
||||
content: 'Analyzing...',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -297,6 +305,48 @@ describe('LocalSubagentInvocation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should reflect tool rejections in the activity stream as cancelled but not abort the agent', async () => {
|
||||
mockExecutorInstance.run.mockImplementation(async () => {
|
||||
const onActivity = MockLocalAgentExecutor.create.mock.calls[0][2];
|
||||
|
||||
if (onActivity) {
|
||||
onActivity({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: { name: 'ls', args: {}, callId: 'call1' },
|
||||
} as SubagentActivityEvent);
|
||||
onActivity({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'MockAgent',
|
||||
type: 'ERROR',
|
||||
data: {
|
||||
name: 'ls',
|
||||
callId: 'call1',
|
||||
error: `${SUBAGENT_REJECTED_ERROR_PREFIX} Please acknowledge this, rethink your strategy, and try a different approach. If you cannot proceed without the rejected operation, summarize the issue and use \`complete_task\` to report your findings and the blocker.`,
|
||||
errorType: SubagentActivityErrorType.REJECTED,
|
||||
},
|
||||
} as SubagentActivityEvent);
|
||||
}
|
||||
return {
|
||||
result: 'Rethinking...',
|
||||
terminate_reason: AgentTerminateMode.GOAL,
|
||||
};
|
||||
});
|
||||
|
||||
await invocation.execute(signal, updateOutput);
|
||||
|
||||
expect(updateOutput).toHaveBeenCalledTimes(4);
|
||||
const lastCall = updateOutput.mock.calls[3][0] as SubagentProgress;
|
||||
expect(lastCall.recentActivity).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'tool_call',
|
||||
content: 'ls',
|
||||
status: 'cancelled',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should run successfully without an updateOutput callback', async () => {
|
||||
mockExecutorInstance.run.mockImplementation(async () => {
|
||||
const onActivity = MockLocalAgentExecutor.create.mock.calls[0][2];
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
type SubagentProgress,
|
||||
type SubagentActivityItem,
|
||||
AgentTerminateMode,
|
||||
SubagentActivityErrorType,
|
||||
SUBAGENT_REJECTED_ERROR_PREFIX,
|
||||
SUBAGENT_CANCELLED_ERROR_MESSAGE,
|
||||
} from './types.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
@@ -120,7 +123,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
lastItem.type === 'thought' &&
|
||||
lastItem.status === 'running'
|
||||
) {
|
||||
lastItem.content += text;
|
||||
lastItem.content = text;
|
||||
} else {
|
||||
recentActivity.push({
|
||||
id: randomUUID(),
|
||||
@@ -171,12 +174,19 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
}
|
||||
case 'ERROR': {
|
||||
const error = String(activity.data['error']);
|
||||
const isCancellation = error === 'Request cancelled.';
|
||||
const errorType = activity.data['errorType'];
|
||||
const isCancellation =
|
||||
errorType === SubagentActivityErrorType.CANCELLED ||
|
||||
error === SUBAGENT_CANCELLED_ERROR_MESSAGE;
|
||||
const isRejection =
|
||||
errorType === SubagentActivityErrorType.REJECTED ||
|
||||
error.startsWith(SUBAGENT_REJECTED_ERROR_PREFIX);
|
||||
|
||||
const toolName = activity.data['name']
|
||||
? String(activity.data['name'])
|
||||
: undefined;
|
||||
|
||||
if (toolName && isCancellation) {
|
||||
if (toolName && (isCancellation || isRejection)) {
|
||||
for (let i = recentActivity.length - 1; i >= 0; i--) {
|
||||
if (
|
||||
recentActivity[i].type === 'tool_call' &&
|
||||
@@ -188,13 +198,27 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (toolName) {
|
||||
// Mark non-rejection/non-cancellation errors as 'error'
|
||||
for (let i = recentActivity.length - 1; i >= 0; i--) {
|
||||
if (
|
||||
recentActivity[i].type === 'tool_call' &&
|
||||
recentActivity[i].content === toolName &&
|
||||
recentActivity[i].status === 'running'
|
||||
) {
|
||||
recentActivity[i].status = 'error';
|
||||
updated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recentActivity.push({
|
||||
id: randomUUID(),
|
||||
type: 'thought', // Treat errors as thoughts for now, or add an error type
|
||||
content: isCancellation ? error : `Error: ${error}`,
|
||||
status: isCancellation ? 'cancelled' : 'error',
|
||||
type: 'thought',
|
||||
content:
|
||||
isCancellation || isRejection ? error : `Error: ${error}`,
|
||||
status: isCancellation || isRejection ? 'cancelled' : 'error',
|
||||
});
|
||||
updated = true;
|
||||
break;
|
||||
|
||||
@@ -65,6 +65,18 @@ export type RemoteAgentInputs = { query: string };
|
||||
/**
|
||||
* Structured events emitted during subagent execution for user observability.
|
||||
*/
|
||||
export enum SubagentActivityErrorType {
|
||||
REJECTED = 'REJECTED',
|
||||
CANCELLED = 'CANCELLED',
|
||||
GENERIC = 'GENERIC',
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard error messages for subagent activities.
|
||||
*/
|
||||
export const SUBAGENT_REJECTED_ERROR_PREFIX = 'User rejected this operation.';
|
||||
export const SUBAGENT_CANCELLED_ERROR_MESSAGE = 'Request cancelled.';
|
||||
|
||||
export interface SubagentActivityEvent {
|
||||
isSubagentActivityEvent: true;
|
||||
agentName: string;
|
||||
|
||||
@@ -9,6 +9,8 @@ import * as path from 'node:path';
|
||||
import { inspect } from 'node:util';
|
||||
import process from 'node:process';
|
||||
import { z } from 'zod';
|
||||
import type { ConversationRecord } from '../services/chatRecordingService.js';
|
||||
export type { ConversationRecord };
|
||||
import {
|
||||
AuthType,
|
||||
createContentGenerator,
|
||||
@@ -228,6 +230,25 @@ export interface ResolvedExtensionSetting {
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface TrajectoryProvider {
|
||||
/** Prefix used to identify sessions from this provider (e.g., 'ext:') */
|
||||
prefix: string;
|
||||
/** Optional display name for UI Tabs */
|
||||
displayName?: string;
|
||||
/** Return an array of conversational tags/ids */
|
||||
listSessions(workspaceUri?: string): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
mtime: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
messageCount?: number;
|
||||
}>
|
||||
>;
|
||||
/** Load a single conversation payload */
|
||||
loadSession(id: string): Promise<ConversationRecord | null>;
|
||||
}
|
||||
|
||||
export interface AgentRunConfig {
|
||||
maxTimeMinutes?: number;
|
||||
maxTurns?: number;
|
||||
@@ -377,6 +398,8 @@ export interface GeminiCLIExtension {
|
||||
* Used to migrate an extension to a new repository source.
|
||||
*/
|
||||
migratedTo?: string;
|
||||
/** Loaded JS module for trajectory decoding */
|
||||
trajectoryProviderModule?: TrajectoryProvider;
|
||||
}
|
||||
|
||||
export interface ExtensionInstallMetadata {
|
||||
|
||||
@@ -179,28 +179,6 @@ export * from './agents/agentLoader.js';
|
||||
export * from './agents/local-executor.js';
|
||||
export * from './agents/agent-scheduler.js';
|
||||
|
||||
// Export agent session interface
|
||||
export * from './agent/legacy-agent-session.js';
|
||||
export * from './agent/event-translator.js';
|
||||
export * from './agent/content-utils.js';
|
||||
// Agent event types — namespaced to avoid collisions with existing exports
|
||||
export type {
|
||||
AgentEvent,
|
||||
AgentEventCommon,
|
||||
AgentEventData,
|
||||
AgentEvents as AgentEventMap,
|
||||
AgentSend,
|
||||
AgentSession,
|
||||
ContentPart,
|
||||
ErrorData,
|
||||
StreamEnd,
|
||||
StreamEndReason,
|
||||
StreamStart,
|
||||
Trajectory,
|
||||
Usage as AgentUsage,
|
||||
WithMeta,
|
||||
} from './agent/types.js';
|
||||
|
||||
// Export specific tool logic
|
||||
export * from './tools/read-file.js';
|
||||
export * from './tools/ls.js';
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
toolName = "enter_plan_mode"
|
||||
decision = "ask_user"
|
||||
priority = 50
|
||||
interactive = true
|
||||
|
||||
[[rule]]
|
||||
toolName = "enter_plan_mode"
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
interactive = false
|
||||
|
||||
[[rule]]
|
||||
toolName = "enter_plan_mode"
|
||||
@@ -46,6 +53,13 @@ toolName = "exit_plan_mode"
|
||||
decision = "ask_user"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
interactive = true
|
||||
|
||||
[[rule]]
|
||||
toolName = "exit_plan_mode"
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
interactive = false
|
||||
|
||||
[[rule]]
|
||||
toolName = "exit_plan_mode"
|
||||
|
||||
@@ -45,6 +45,7 @@ toolName = ["enter_plan_mode", "exit_plan_mode"]
|
||||
decision = "deny"
|
||||
priority = 999
|
||||
modes = ["yolo"]
|
||||
interactive = true
|
||||
|
||||
# Allow everything else in YOLO mode
|
||||
[[rule]]
|
||||
|
||||
@@ -3343,4 +3343,121 @@ describe('PolicyEngine', () => {
|
||||
expect(excluded.has('test-tool')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactive matching', () => {
|
||||
it('should ignore interactive rules in non-interactive mode', async () => {
|
||||
const engine = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolName: 'my_tool',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
interactive: true,
|
||||
},
|
||||
],
|
||||
nonInteractive: true,
|
||||
defaultDecision: PolicyDecision.DENY,
|
||||
});
|
||||
|
||||
const result = await engine.check(
|
||||
{ name: 'my_tool', args: {} },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should allow interactive rules in interactive mode', async () => {
|
||||
const engine = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolName: 'my_tool',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
interactive: true,
|
||||
},
|
||||
],
|
||||
nonInteractive: false,
|
||||
defaultDecision: PolicyDecision.DENY,
|
||||
});
|
||||
|
||||
const result = await engine.check(
|
||||
{ name: 'my_tool', args: {} },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should ignore non-interactive rules in interactive mode', async () => {
|
||||
const engine = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolName: 'my_tool',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
interactive: false,
|
||||
},
|
||||
],
|
||||
nonInteractive: false,
|
||||
defaultDecision: PolicyDecision.DENY,
|
||||
});
|
||||
|
||||
const result = await engine.check(
|
||||
{ name: 'my_tool', args: {} },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should allow non-interactive rules in non-interactive mode', async () => {
|
||||
const engine = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolName: 'my_tool',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
interactive: false,
|
||||
},
|
||||
],
|
||||
nonInteractive: true,
|
||||
defaultDecision: PolicyDecision.DENY,
|
||||
});
|
||||
|
||||
const result = await engine.check(
|
||||
{ name: 'my_tool', args: {} },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should apply rules without interactive flag to both', async () => {
|
||||
const rule: PolicyRule = {
|
||||
toolName: 'my_tool',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
};
|
||||
|
||||
const engineInteractive = new PolicyEngine({
|
||||
rules: [rule],
|
||||
nonInteractive: false,
|
||||
defaultDecision: PolicyDecision.DENY,
|
||||
});
|
||||
const engineNonInteractive = new PolicyEngine({
|
||||
rules: [rule],
|
||||
nonInteractive: true,
|
||||
defaultDecision: PolicyDecision.DENY,
|
||||
});
|
||||
|
||||
expect(
|
||||
(
|
||||
await engineInteractive.check(
|
||||
{ name: 'my_tool', args: {} },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
expect(
|
||||
(
|
||||
await engineNonInteractive.check(
|
||||
{ name: 'my_tool', args: {} },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,7 @@ function ruleMatches(
|
||||
stringifiedArgs: string | undefined,
|
||||
serverName: string | undefined,
|
||||
currentApprovalMode: ApprovalMode,
|
||||
nonInteractive: boolean,
|
||||
toolAnnotations?: Record<string, unknown>,
|
||||
subagent?: string,
|
||||
): boolean {
|
||||
@@ -146,6 +147,16 @@ function ruleMatches(
|
||||
}
|
||||
}
|
||||
|
||||
// Check interactive if specified
|
||||
if ('interactive' in rule && rule.interactive !== undefined) {
|
||||
if (rule.interactive && nonInteractive) {
|
||||
return false;
|
||||
}
|
||||
if (!rule.interactive && !nonInteractive) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -443,6 +454,7 @@ export class PolicyEngine {
|
||||
stringifiedArgs,
|
||||
serverName,
|
||||
this.approvalMode,
|
||||
this.nonInteractive,
|
||||
toolAnnotations,
|
||||
subagent,
|
||||
),
|
||||
@@ -521,6 +533,7 @@ export class PolicyEngine {
|
||||
stringifiedArgs,
|
||||
serverName,
|
||||
this.approvalMode,
|
||||
this.nonInteractive,
|
||||
toolAnnotations,
|
||||
subagent,
|
||||
)
|
||||
@@ -713,6 +726,7 @@ export class PolicyEngine {
|
||||
undefined, // stringifiedArgs
|
||||
serverName,
|
||||
this.approvalMode,
|
||||
this.nonInteractive,
|
||||
annotations,
|
||||
);
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ const PolicyRuleSchema = z.object({
|
||||
'priority must be <= 999 to prevent tier overflow. Priorities >= 1000 would jump to the next tier.',
|
||||
}),
|
||||
modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
|
||||
interactive: z.boolean().optional(),
|
||||
toolAnnotations: z.record(z.any()).optional(),
|
||||
allow_redirection: z.boolean().optional(),
|
||||
deny_message: z.string().optional(),
|
||||
@@ -475,6 +476,7 @@ export async function loadPoliciesFromToml(
|
||||
decision: rule.decision,
|
||||
priority: transformPriority(rule.priority, tier),
|
||||
modes: rule.modes,
|
||||
interactive: rule.interactive,
|
||||
toolAnnotations: rule.toolAnnotations,
|
||||
allowRedirection: rule.allow_redirection,
|
||||
source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`,
|
||||
|
||||
@@ -152,6 +152,13 @@ export interface PolicyRule {
|
||||
*/
|
||||
modes?: ApprovalMode[];
|
||||
|
||||
/**
|
||||
* If true, this rule only applies to interactive environments.
|
||||
* If false, this rule only applies to non-interactive environments.
|
||||
* If undefined, it applies to both interactive and non-interactive environments.
|
||||
*/
|
||||
interactive?: boolean;
|
||||
|
||||
/**
|
||||
* If true, allows command redirection even if the policy engine would normally
|
||||
* downgrade ALLOW to ASK_USER for redirected commands.
|
||||
|
||||
@@ -175,6 +175,7 @@ export class PromptProvider {
|
||||
planningWorkflow: this.withSection(
|
||||
'planningWorkflow',
|
||||
() => ({
|
||||
interactive: interactiveMode,
|
||||
planModeToolsList,
|
||||
plansDir: context.config.storage.getPlansDir(),
|
||||
approvedPlanPath: context.config.getApprovedPlanPath(),
|
||||
|
||||
@@ -88,6 +88,7 @@ export interface GitRepoOptions {
|
||||
}
|
||||
|
||||
export interface PlanningWorkflowOptions {
|
||||
interactive: boolean;
|
||||
planModeToolsList: string;
|
||||
plansDir: string;
|
||||
approvedPlanPath?: string;
|
||||
@@ -513,7 +514,7 @@ export function renderPlanningWorkflow(
|
||||
return `
|
||||
# Active Approval Mode: Plan
|
||||
|
||||
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`${options.plansDir}/\` and get user approval before editing source code.
|
||||
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`${options.plansDir}/\` and ${options.interactive ? 'get user approval before editing source code.' : 'create a design document before proceeding autonomously.'}
|
||||
|
||||
## Available Tools
|
||||
The following tools are available in Plan Mode:
|
||||
@@ -550,7 +551,7 @@ Write the implementation plan to \`${options.plansDir}/\`. The plan's structure
|
||||
- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies.
|
||||
|
||||
### 4. Review & Approval
|
||||
Use the ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} tool to present the plan and formally request approval.
|
||||
Use the ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} tool to present the plan and ${options.interactive ? 'formally request approval.' : 'begin implementation.'}
|
||||
|
||||
${renderApprovedPlanSection(options.approvedPlanPath)}`.trim();
|
||||
}
|
||||
@@ -711,7 +712,7 @@ function newApplicationSteps(options: PrimaryWorkflowsOptions): string {
|
||||
// standard 'Execution' loop handle implementation once the plan is approved.
|
||||
if (options.enableEnterPlanModeTool) {
|
||||
return `
|
||||
1. **Mandatory Planning:** You MUST use the ${formatToolName(ENTER_PLAN_MODE_TOOL_NAME)} tool to draft a comprehensive design document and obtain user approval before writing any code.
|
||||
1. **Mandatory Planning:** You MUST use the ${formatToolName(ENTER_PLAN_MODE_TOOL_NAME)} tool to draft a comprehensive design document${options.interactive ? ' and obtain user approval' : ''} before writing any code.
|
||||
2. **Design Constraints:** When drafting your plan, adhere to these defaults unless explicitly overridden by the user:
|
||||
- **Goal:** Autonomously design a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, typography, and interactive feedback.
|
||||
- **Visuals:** Describe your strategy for sourcing or generating placeholders (e.g., stylized CSS shapes, gradients, procedurally generated patterns) to ensure a visually complete prototype. Never plan for assets that cannot be locally generated.
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { MacOsSandboxManager } from './MacOsSandboxManager.js';
|
||||
import { ShellExecutionService } from '../../services/shellExecutionService.js';
|
||||
import { getSecureSanitizationConfig } from '../../services/environmentSanitization.js';
|
||||
import { type SandboxedCommand } from '../../services/sandboxManager.js';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
|
||||
/**
|
||||
* A simple asynchronous wrapper for execFile that returns the exit status,
|
||||
* stdout, and stderr. Unlike spawnSync, this does not block the Node.js
|
||||
* event loop, allowing the local HTTP test server to function.
|
||||
*/
|
||||
async function runCommand(command: SandboxedCommand) {
|
||||
try {
|
||||
const { stdout, stderr } = await promisify(execFile)(
|
||||
command.program,
|
||||
command.args,
|
||||
{
|
||||
cwd: command.cwd,
|
||||
env: command.env,
|
||||
encoding: 'utf-8',
|
||||
},
|
||||
);
|
||||
return { status: 0, stdout, stderr };
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
code?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
return {
|
||||
status: err.code ?? 1,
|
||||
stdout: err.stdout ?? '',
|
||||
stderr: err.stderr ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(os.platform() !== 'darwin')(
|
||||
'MacOsSandboxManager Integration',
|
||||
() => {
|
||||
describe('Basic Execution', () => {
|
||||
it('should execute commands within the workspace', async () => {
|
||||
const manager = new MacOsSandboxManager({ workspace: process.cwd() });
|
||||
const command = await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['sandbox test'],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const execResult = await runCommand(command);
|
||||
|
||||
expect(execResult.status).toBe(0);
|
||||
expect(execResult.stdout.trim()).toBe('sandbox test');
|
||||
});
|
||||
|
||||
it('should support interactive pseudo-terminals (node-pty)', async () => {
|
||||
const manager = new MacOsSandboxManager({ workspace: process.cwd() });
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Verify that node-pty file descriptors are successfully allocated inside the sandbox
|
||||
// by using the bash [ -t 1 ] idiom to check if stdout is a TTY.
|
||||
const handle = await ShellExecutionService.execute(
|
||||
'bash -c "if [ -t 1 ]; then echo True; else echo False; fi"',
|
||||
process.cwd(),
|
||||
() => {},
|
||||
abortController.signal,
|
||||
true,
|
||||
{
|
||||
sanitizationConfig: getSecureSanitizationConfig(),
|
||||
sandboxManager: manager,
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handle.result;
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toContain('True');
|
||||
});
|
||||
});
|
||||
|
||||
describe('File System Access', () => {
|
||||
it('should block file system access outside the workspace', async () => {
|
||||
const manager = new MacOsSandboxManager({ workspace: process.cwd() });
|
||||
const blockedPath = '/Users/Shared/.gemini_test_sandbox_blocked';
|
||||
|
||||
const command = await manager.prepareCommand({
|
||||
command: 'touch',
|
||||
args: [blockedPath],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
const execResult = await runCommand(command);
|
||||
|
||||
expect(execResult.status).not.toBe(0);
|
||||
expect(execResult.stderr).toContain('Operation not permitted');
|
||||
});
|
||||
|
||||
it('should grant file system access to explicitly allowed paths', async () => {
|
||||
// Create a unique temporary directory to prevent artifacts and test flakiness
|
||||
const allowedDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-sandbox-test-'),
|
||||
);
|
||||
|
||||
try {
|
||||
const manager = new MacOsSandboxManager({
|
||||
workspace: process.cwd(),
|
||||
allowedPaths: [allowedDir],
|
||||
});
|
||||
const testFile = path.join(allowedDir, 'test.txt');
|
||||
|
||||
const command = await manager.prepareCommand({
|
||||
command: 'touch',
|
||||
args: [testFile],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const execResult = await runCommand(command);
|
||||
|
||||
expect(execResult.status).toBe(0);
|
||||
} finally {
|
||||
fs.rmSync(allowedDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Network Access', () => {
|
||||
let testServer: http.Server;
|
||||
let testServerUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testServer = http.createServer((_, res) => {
|
||||
// Ensure connections are closed immediately to prevent hanging
|
||||
res.setHeader('Connection', 'close');
|
||||
res.writeHead(200);
|
||||
res.end('ok');
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
testServer.on('error', reject);
|
||||
testServer.listen(0, '127.0.0.1', () => {
|
||||
const address = testServer.address() as import('net').AddressInfo;
|
||||
testServerUrl = `http://127.0.0.1:${address.port}`;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (testServer) {
|
||||
await new Promise<void>((resolve) => {
|
||||
testServer.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should block network access by default', async () => {
|
||||
const manager = new MacOsSandboxManager({ workspace: process.cwd() });
|
||||
const command = await manager.prepareCommand({
|
||||
command: 'curl',
|
||||
args: ['-s', '--connect-timeout', '1', testServerUrl],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const execResult = await runCommand(command);
|
||||
|
||||
expect(execResult.status).not.toBe(0);
|
||||
});
|
||||
|
||||
it('should grant network access when explicitly allowed', async () => {
|
||||
const manager = new MacOsSandboxManager({
|
||||
workspace: process.cwd(),
|
||||
networkAccess: true,
|
||||
});
|
||||
const command = await manager.prepareCommand({
|
||||
command: 'curl',
|
||||
args: ['-s', '--connect-timeout', '1', testServerUrl],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const execResult = await runCommand(command);
|
||||
|
||||
expect(execResult.status).toBe(0);
|
||||
expect(execResult.stdout.trim()).toBe('ok');
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { MacOsSandboxManager } from './MacOsSandboxManager.js';
|
||||
import * as seatbeltArgsBuilder from './seatbeltArgsBuilder.js';
|
||||
|
||||
describe('MacOsSandboxManager', () => {
|
||||
const mockWorkspace = '/test/workspace';
|
||||
const mockAllowedPaths = ['/test/allowed'];
|
||||
const mockNetworkAccess = true;
|
||||
|
||||
let manager: MacOsSandboxManager;
|
||||
let buildArgsSpy: MockInstance<typeof seatbeltArgsBuilder.buildSeatbeltArgs>;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new MacOsSandboxManager({
|
||||
workspace: mockWorkspace,
|
||||
allowedPaths: mockAllowedPaths,
|
||||
networkAccess: mockNetworkAccess,
|
||||
});
|
||||
|
||||
buildArgsSpy = vi
|
||||
.spyOn(seatbeltArgsBuilder, 'buildSeatbeltArgs')
|
||||
.mockReturnValue([
|
||||
'-p',
|
||||
'(mock profile)',
|
||||
'-D',
|
||||
'WORKSPACE=/test/workspace',
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should correctly invoke buildSeatbeltArgs with the configured options', async () => {
|
||||
await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: mockWorkspace,
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(buildArgsSpy).toHaveBeenCalledWith({
|
||||
workspace: mockWorkspace,
|
||||
allowedPaths: mockAllowedPaths,
|
||||
networkAccess: mockNetworkAccess,
|
||||
});
|
||||
});
|
||||
|
||||
it('should format the executable and arguments correctly for sandbox-exec', async () => {
|
||||
const result = await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: mockWorkspace,
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.program).toBe('/usr/bin/sandbox-exec');
|
||||
expect(result.args).toEqual([
|
||||
'-p',
|
||||
'(mock profile)',
|
||||
'-D',
|
||||
'WORKSPACE=/test/workspace',
|
||||
'--',
|
||||
'echo',
|
||||
'hello',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should correctly pass through the cwd to the resulting command', async () => {
|
||||
const result = await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: '/test/different/cwd',
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.cwd).toBe('/test/different/cwd');
|
||||
});
|
||||
|
||||
it('should apply environment sanitization via the default mechanisms', async () => {
|
||||
const result = await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: mockWorkspace,
|
||||
env: {
|
||||
SAFE_VAR: '1',
|
||||
GITHUB_TOKEN: 'sensitive',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.env['SAFE_VAR']).toBe('1');
|
||||
expect(result.env['GITHUB_TOKEN']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type SandboxManager,
|
||||
type SandboxRequest,
|
||||
type SandboxedCommand,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
getSecureSanitizationConfig,
|
||||
type EnvironmentSanitizationConfig,
|
||||
} from '../../services/environmentSanitization.js';
|
||||
import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js';
|
||||
|
||||
/**
|
||||
* Options for configuring the MacOsSandboxManager.
|
||||
*/
|
||||
export interface MacOsSandboxOptions {
|
||||
/** The primary workspace path to allow access to within the sandbox. */
|
||||
workspace: string;
|
||||
/** Additional paths to allow access to within the sandbox. */
|
||||
allowedPaths?: string[];
|
||||
/** Whether network access is allowed. */
|
||||
networkAccess?: boolean;
|
||||
/** Optional base sanitization config. */
|
||||
sanitizationConfig?: EnvironmentSanitizationConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* A SandboxManager implementation for macOS that uses Seatbelt.
|
||||
*/
|
||||
export class MacOsSandboxManager implements SandboxManager {
|
||||
constructor(private readonly options: MacOsSandboxOptions) {}
|
||||
|
||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
const sanitizationConfig = getSecureSanitizationConfig(
|
||||
req.config?.sanitizationConfig,
|
||||
this.options.sanitizationConfig,
|
||||
);
|
||||
|
||||
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
|
||||
|
||||
const sandboxArgs = buildSeatbeltArgs({
|
||||
workspace: this.options.workspace,
|
||||
allowedPaths: this.options.allowedPaths,
|
||||
networkAccess: this.options.networkAccess,
|
||||
});
|
||||
|
||||
return {
|
||||
program: '/usr/bin/sandbox-exec',
|
||||
args: [...sandboxArgs, '--', req.command, ...req.args],
|
||||
env: sanitizedEnv,
|
||||
cwd: req.cwd,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* The base macOS Seatbelt (SBPL) profile for tool execution.
|
||||
*
|
||||
* This uses a strict allowlist (deny default) but imports Apple's base system profile
|
||||
* to handle undocumented internal dependencies, sysctls, and IPC mach ports required
|
||||
* by standard tools to avoid "Abort trap: 6".
|
||||
*/
|
||||
export const BASE_SEATBELT_PROFILE = `(version 1)
|
||||
(deny default)
|
||||
|
||||
(import "system.sb")
|
||||
|
||||
; Core execution requirements
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
(allow signal (target same-sandbox))
|
||||
(allow process-info* (target same-sandbox))
|
||||
|
||||
; Allow basic read access to system frameworks and libraries required to run
|
||||
(allow file-read*
|
||||
(subpath "/System")
|
||||
(subpath "/usr/lib")
|
||||
(subpath "/usr/share")
|
||||
(subpath "/usr/bin")
|
||||
(subpath "/bin")
|
||||
(subpath "/sbin")
|
||||
(subpath "/usr/local/bin")
|
||||
(subpath "/opt/homebrew")
|
||||
(subpath "/Library")
|
||||
(subpath "/private/var/run")
|
||||
(subpath "/private/var/db")
|
||||
(subpath "/private/etc")
|
||||
)
|
||||
|
||||
; PTY and Terminal support
|
||||
(allow pseudo-tty)
|
||||
(allow file-read* file-write* file-ioctl (literal "/dev/ptmx"))
|
||||
(allow file-read* file-write* file-ioctl (regex #"^/dev/ttys[0-9]+"))
|
||||
|
||||
; Allow read/write access to temporary directories and common device nodes
|
||||
(allow file-read* file-write*
|
||||
(literal "/dev/null")
|
||||
(literal "/dev/zero")
|
||||
(subpath "/tmp")
|
||||
(subpath "/private/tmp")
|
||||
(subpath (param "TMPDIR"))
|
||||
)
|
||||
|
||||
; Workspace access using parameterized paths
|
||||
(allow file-read* file-write*
|
||||
(subpath (param "WORKSPACE"))
|
||||
)
|
||||
`;
|
||||
|
||||
/**
|
||||
* The network-specific macOS Seatbelt (SBPL) profile rules.
|
||||
*
|
||||
* These rules are appended to the base profile when network access is enabled,
|
||||
* allowing standard socket creation, DNS resolution, and TLS certificate validation.
|
||||
*/
|
||||
export const NETWORK_SEATBELT_PROFILE = `
|
||||
; Network Access
|
||||
(allow network*)
|
||||
|
||||
(allow system-socket
|
||||
(require-all
|
||||
(socket-domain AF_SYSTEM)
|
||||
(socket-protocol 2)
|
||||
)
|
||||
)
|
||||
|
||||
(allow mach-lookup
|
||||
(global-name "com.apple.bsd.dirhelper")
|
||||
(global-name "com.apple.system.opendirectoryd.membership")
|
||||
(global-name "com.apple.SecurityServer")
|
||||
(global-name "com.apple.networkd")
|
||||
(global-name "com.apple.ocspd")
|
||||
(global-name "com.apple.trustd.agent")
|
||||
(global-name "com.apple.mDNSResponder")
|
||||
(global-name "com.apple.mDNSResponderHelper")
|
||||
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
|
||||
(global-name "com.apple.SystemConfiguration.configd")
|
||||
)
|
||||
|
||||
(allow sysctl-read
|
||||
(sysctl-name-regex #"^net.routetable")
|
||||
)
|
||||
`;
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
|
||||
describe('seatbeltArgsBuilder', () => {
|
||||
it('should build a strict allowlist profile allowing the workspace via param', () => {
|
||||
// Mock realpathSync to just return the path for testing
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => p as string);
|
||||
|
||||
const args = buildSeatbeltArgs({ workspace: '/Users/test/workspace' });
|
||||
|
||||
expect(args[0]).toBe('-p');
|
||||
const profile = args[1];
|
||||
expect(profile).toContain('(version 1)');
|
||||
expect(profile).toContain('(deny default)');
|
||||
expect(profile).toContain('(allow process-exec)');
|
||||
expect(profile).toContain('(subpath (param "WORKSPACE"))');
|
||||
expect(profile).not.toContain('(allow network*)');
|
||||
|
||||
expect(args).toContain('-D');
|
||||
expect(args).toContain('WORKSPACE=/Users/test/workspace');
|
||||
expect(args).toContain(`TMPDIR=${os.tmpdir()}`);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should allow network when networkAccess is true', () => {
|
||||
const args = buildSeatbeltArgs({ workspace: '/test', networkAccess: true });
|
||||
const profile = args[1];
|
||||
expect(profile).toContain('(allow network*)');
|
||||
});
|
||||
|
||||
it('should parameterize allowed paths and normalize them', () => {
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
|
||||
if (p === '/test/symlink') return '/test/real_path';
|
||||
return p as string;
|
||||
});
|
||||
|
||||
const args = buildSeatbeltArgs({
|
||||
workspace: '/test',
|
||||
allowedPaths: ['/custom/path1', '/test/symlink'],
|
||||
});
|
||||
|
||||
const profile = args[1];
|
||||
expect(profile).toContain('(subpath (param "ALLOWED_PATH_0"))');
|
||||
expect(profile).toContain('(subpath (param "ALLOWED_PATH_1"))');
|
||||
|
||||
expect(args).toContain('-D');
|
||||
expect(args).toContain('ALLOWED_PATH_0=/custom/path1');
|
||||
expect(args).toContain('ALLOWED_PATH_1=/test/real_path');
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should resolve parent directories if a file does not exist', () => {
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
|
||||
if (p === '/test/symlink/nonexistent.txt') {
|
||||
const error = new Error('ENOENT');
|
||||
Object.assign(error, { code: 'ENOENT' });
|
||||
throw error;
|
||||
}
|
||||
if (p === '/test/symlink') {
|
||||
return '/test/real_path';
|
||||
}
|
||||
return p as string;
|
||||
});
|
||||
|
||||
const args = buildSeatbeltArgs({
|
||||
workspace: '/test/symlink/nonexistent.txt',
|
||||
});
|
||||
|
||||
expect(args).toContain('WORKSPACE=/test/real_path/nonexistent.txt');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should throw if realpathSync throws a non-ENOENT error', () => {
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation(() => {
|
||||
const error = new Error('Permission denied');
|
||||
Object.assign(error, { code: 'EACCES' });
|
||||
throw error;
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
buildSeatbeltArgs({
|
||||
workspace: '/test/workspace',
|
||||
}),
|
||||
).toThrow('Permission denied');
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
BASE_SEATBELT_PROFILE,
|
||||
NETWORK_SEATBELT_PROFILE,
|
||||
} from './baseProfile.js';
|
||||
|
||||
/**
|
||||
* Options for building macOS Seatbelt arguments.
|
||||
*/
|
||||
export interface SeatbeltArgsOptions {
|
||||
/** The primary workspace path to allow access to. */
|
||||
workspace: string;
|
||||
/** Additional paths to allow access to. */
|
||||
allowedPaths?: string[];
|
||||
/** Whether to allow network access. */
|
||||
networkAccess?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves symlinks for a given path to prevent sandbox escapes.
|
||||
* If a file does not exist (ENOENT), it recursively resolves the parent directory.
|
||||
* Other errors (e.g. EACCES) are re-thrown.
|
||||
*/
|
||||
function tryRealpath(p: string): string {
|
||||
try {
|
||||
return fs.realpathSync(p);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
|
||||
const parentDir = path.dirname(p);
|
||||
if (parentDir === p) {
|
||||
return p;
|
||||
}
|
||||
return path.join(tryRealpath(parentDir), path.basename(p));
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the arguments array for sandbox-exec using a strict allowlist profile.
|
||||
* It relies on parameters passed to sandbox-exec via the -D flag to avoid
|
||||
* string interpolation vulnerabilities, and normalizes paths against symlink escapes.
|
||||
*
|
||||
* Returns arguments up to the end of sandbox-exec configuration (e.g. ['-p', '<profile>', '-D', ...])
|
||||
* Does not include the final '--' separator or the command to run.
|
||||
*/
|
||||
export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] {
|
||||
let profile = BASE_SEATBELT_PROFILE + '\n';
|
||||
const args: string[] = [];
|
||||
|
||||
const workspacePath = tryRealpath(options.workspace);
|
||||
args.push('-D', `WORKSPACE=${workspacePath}`);
|
||||
|
||||
const tmpPath = tryRealpath(os.tmpdir());
|
||||
args.push('-D', `TMPDIR=${tmpPath}`);
|
||||
|
||||
if (options.allowedPaths) {
|
||||
for (let i = 0; i < options.allowedPaths.length; i++) {
|
||||
const allowedPath = tryRealpath(options.allowedPaths[i]);
|
||||
args.push('-D', `ALLOWED_PATH_${i}=${allowedPath}`);
|
||||
profile += `(allow file-read* file-write* (subpath (param "ALLOWED_PATH_${i}")))\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.networkAccess) {
|
||||
profile += NETWORK_SEATBELT_PROFILE;
|
||||
}
|
||||
|
||||
args.unshift('-p', profile);
|
||||
|
||||
return args;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createSandboxManager,
|
||||
} from './sandboxManager.js';
|
||||
import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js';
|
||||
import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js';
|
||||
|
||||
describe('NoopSandboxManager', () => {
|
||||
const sandboxManager = new NoopSandboxManager();
|
||||
@@ -124,23 +125,20 @@ describe('createSandboxManager', () => {
|
||||
expect(manager).toBeInstanceOf(NoopSandboxManager);
|
||||
});
|
||||
|
||||
it('should return LinuxSandboxManager if sandboxing is enabled and platform is linux', () => {
|
||||
const osSpy = vi.spyOn(os, 'platform').mockReturnValue('linux');
|
||||
try {
|
||||
const manager = createSandboxManager(true, '/workspace');
|
||||
expect(manager).toBeInstanceOf(LinuxSandboxManager);
|
||||
} finally {
|
||||
osSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return LocalSandboxManager if sandboxing is enabled and platform is not linux', () => {
|
||||
const osSpy = vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
||||
try {
|
||||
const manager = createSandboxManager(true, '/workspace');
|
||||
expect(manager).toBeInstanceOf(LocalSandboxManager);
|
||||
} finally {
|
||||
osSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
it.each([
|
||||
{ platform: 'linux', expected: LinuxSandboxManager },
|
||||
{ platform: 'darwin', expected: MacOsSandboxManager },
|
||||
{ platform: 'win32', expected: LocalSandboxManager },
|
||||
] as const)(
|
||||
'should return $expected.name if sandboxing is enabled and platform is $platform',
|
||||
({ platform, expected }) => {
|
||||
const osSpy = vi.spyOn(os, 'platform').mockReturnValue(platform);
|
||||
try {
|
||||
const manager = createSandboxManager(true, '/workspace');
|
||||
expect(manager).toBeInstanceOf(expected);
|
||||
} finally {
|
||||
osSpy.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type EnvironmentSanitizationConfig,
|
||||
} from './environmentSanitization.js';
|
||||
import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js';
|
||||
import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js';
|
||||
|
||||
/**
|
||||
* Request for preparing a command to run in a sandbox.
|
||||
@@ -98,6 +99,9 @@ export function createSandboxManager(
|
||||
if (os.platform() === 'linux') {
|
||||
return new LinuxSandboxManager({ workspace });
|
||||
}
|
||||
if (os.platform() === 'darwin') {
|
||||
return new MacOsSandboxManager({ workspace });
|
||||
}
|
||||
return new LocalSandboxManager();
|
||||
}
|
||||
return new NoopSandboxManager();
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('ExitPlanModeTool', () => {
|
||||
storage: {
|
||||
getPlansDir: vi.fn().mockReturnValue(mockPlansDir),
|
||||
} as unknown as Config['storage'],
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
tool = new ExitPlanModeTool(
|
||||
mockConfig as Config,
|
||||
@@ -359,6 +360,36 @@ Ask the user for specific feedback on how to improve the plan.`,
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllowApprovalMode (internal)', () => {
|
||||
it('should return YOLO when config.isInteractive() is false', async () => {
|
||||
mockConfig.isInteractive = vi.fn().mockReturnValue(false);
|
||||
const planRelativePath = createPlanFile('test.md', '# Content');
|
||||
const invocation = tool.build({ plan_path: planRelativePath });
|
||||
|
||||
// Directly call execute to trigger the internal getAllowApprovalMode
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('YOLO mode');
|
||||
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
|
||||
ApprovalMode.YOLO,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return DEFAULT when config.isInteractive() is true', async () => {
|
||||
mockConfig.isInteractive = vi.fn().mockReturnValue(true);
|
||||
const planRelativePath = createPlanFile('test.md', '# Content');
|
||||
const invocation = tool.build({ plan_path: planRelativePath });
|
||||
|
||||
// Directly call execute to trigger the internal getAllowApprovalMode
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('Default mode');
|
||||
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getApprovalModeDescription (internal)', () => {
|
||||
it('should handle all valid approval modes', async () => {
|
||||
const planRelativePath = createPlanFile('test.md', '# Content');
|
||||
@@ -387,6 +418,10 @@ Ask the user for specific feedback on how to improve the plan.`,
|
||||
ApprovalMode.DEFAULT,
|
||||
'Default mode (edits will require confirmation)',
|
||||
);
|
||||
await testMode(
|
||||
ApprovalMode.YOLO,
|
||||
'YOLO mode (all tool calls auto-approved)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw for invalid post-planning modes', async () => {
|
||||
@@ -409,7 +444,6 @@ Ask the user for specific feedback on how to improve the plan.`,
|
||||
).rejects.toThrow(/Unexpected approval mode/);
|
||||
};
|
||||
|
||||
await testInvalidMode(ApprovalMode.YOLO);
|
||||
await testInvalidMode(ApprovalMode.PLAN);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
type ToolResult,
|
||||
Kind,
|
||||
type ToolExitPlanModeConfirmationDetails,
|
||||
type ToolConfirmationPayload,
|
||||
type ToolExitPlanModeConfirmationPayload,
|
||||
ToolConfirmationOutcome,
|
||||
type ToolConfirmationPayload,
|
||||
type ToolExitPlanModeConfirmationDetails,
|
||||
type ToolExitPlanModeConfirmationPayload,
|
||||
type ToolResult,
|
||||
} from './tools.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import path from 'node:path';
|
||||
@@ -151,7 +151,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
this.confirmationOutcome = ToolConfirmationOutcome.ProceedOnce;
|
||||
this.approvalPayload = {
|
||||
approved: true,
|
||||
approvalMode: ApprovalMode.DEFAULT,
|
||||
approvalMode: this.getAllowApprovalMode(),
|
||||
};
|
||||
return false;
|
||||
}
|
||||
@@ -205,17 +205,15 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
|
||||
// When a user policy grants `allow` for exit_plan_mode, the scheduler
|
||||
// skips the confirmation phase entirely and shouldConfirmExecute is never
|
||||
// called, leaving approvalPayload null. Treat that as an approval with
|
||||
// the default mode — consistent with the ALLOW branch inside
|
||||
// shouldConfirmExecute.
|
||||
// called, leaving approvalPayload null.
|
||||
const payload = this.approvalPayload ?? {
|
||||
approved: true,
|
||||
approvalMode: ApprovalMode.DEFAULT,
|
||||
approvalMode: this.getAllowApprovalMode(),
|
||||
};
|
||||
if (payload.approved) {
|
||||
const newMode = payload.approvalMode ?? ApprovalMode.DEFAULT;
|
||||
|
||||
if (newMode === ApprovalMode.PLAN || newMode === ApprovalMode.YOLO) {
|
||||
if (newMode === ApprovalMode.PLAN) {
|
||||
throw new Error(`Unexpected approval mode: ${newMode}`);
|
||||
}
|
||||
|
||||
@@ -254,4 +252,18 @@ Ask the user for specific feedback on how to improve the plan.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the approval mode to switch to when plan mode is exited via a policy ALLOW.
|
||||
* In non-interactive environments, this defaults to YOLO to allow automated execution.
|
||||
*/
|
||||
private getAllowApprovalMode(): ApprovalMode {
|
||||
if (!this.config.isInteractive()) {
|
||||
// For non-interactive environment requires minimal user action, exit as YOLO mode for plan implementation.
|
||||
return ApprovalMode.YOLO;
|
||||
}
|
||||
// By default, YOLO mode in interactive environment cannot enter/exit plan mode.
|
||||
// Always exit plan mode and move to default approval mode if exit_plan_mode tool is configured with allow decision.
|
||||
return ApprovalMode.DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { coreEvents } from './events.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
|
||||
// Mock fs/promises before importing the module under test
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
access: vi.fn(),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock Storage to return a predictable directory
|
||||
vi.mock('../config/storage.js', () => ({
|
||||
Storage: {
|
||||
getGlobalGeminiDir: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { getBrowserConsentIfNeeded } from './browserConsent.js';
|
||||
|
||||
describe('browserConsent', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(Storage.getGlobalGeminiDir).mockReturnValue('/mock/.gemini');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return true if consent file already exists', async () => {
|
||||
// Consent file exists — fs.access resolves
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
|
||||
const result = await getBrowserConsentIfNeeded();
|
||||
|
||||
expect(result).toBe(true);
|
||||
// Should not emit a consent request
|
||||
const emitSpy = vi.spyOn(coreEvents, 'emitConsentRequest');
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should auto-accept in non-interactive mode (no listeners)', async () => {
|
||||
// Consent file does not exist
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
// No listeners registered
|
||||
vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(0);
|
||||
|
||||
const result = await getBrowserConsentIfNeeded();
|
||||
|
||||
expect(result).toBe(true);
|
||||
// Should persist the consent
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('browser-consent-acknowledged.txt'),
|
||||
expect.stringContaining('consent acknowledged'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should request consent interactively and return true when accepted', async () => {
|
||||
// Consent file does not exist
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
// Simulate interactive mode: there is at least one listener
|
||||
vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1);
|
||||
// Mock emitConsentRequest to auto-confirm
|
||||
vi.spyOn(coreEvents, 'emitConsentRequest').mockImplementation((payload) => {
|
||||
payload.onConfirm(true);
|
||||
});
|
||||
|
||||
const result = await getBrowserConsentIfNeeded();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(coreEvents.emitConsentRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining('Privacy Notice'),
|
||||
}),
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when user declines consent', async () => {
|
||||
// Consent file does not exist
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
// Simulate interactive mode
|
||||
vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1);
|
||||
// Mock emitConsentRequest to decline
|
||||
vi.spyOn(coreEvents, 'emitConsentRequest').mockImplementation((payload) => {
|
||||
payload.onConfirm(false);
|
||||
});
|
||||
|
||||
const result = await getBrowserConsentIfNeeded();
|
||||
|
||||
expect(result).toBe(false);
|
||||
// Should NOT persist consent
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include privacy policy link in the prompt', async () => {
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1);
|
||||
vi.spyOn(coreEvents, 'emitConsentRequest').mockImplementation((payload) => {
|
||||
payload.onConfirm(true);
|
||||
});
|
||||
|
||||
await getBrowserConsentIfNeeded();
|
||||
|
||||
expect(coreEvents.emitConsentRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining('policies.google.com/privacy'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CoreEvent, coreEvents } from './events.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
/** Sentinel file written after the user acknowledges the browser privacy notice. */
|
||||
const BROWSER_CONSENT_FLAG_FILE = 'browser-consent-acknowledged.txt';
|
||||
|
||||
/** Default browser profile directory name within ~/.gemini/ */
|
||||
const BROWSER_PROFILE_DIR = 'cli-browser-profile';
|
||||
|
||||
/**
|
||||
* Ensures the user has acknowledged the browser agent privacy notice.
|
||||
*
|
||||
* On first invocation (per profile), an interactive consent dialog is shown
|
||||
* describing chrome-devtools-mcp's data collection and the fact that browser
|
||||
* content is exposed to the AI model. A sentinel file is written to the
|
||||
* browser profile directory once the user accepts.
|
||||
*
|
||||
* @returns `true` if consent was already given or the user accepted,
|
||||
* `false` if the user declined.
|
||||
*/
|
||||
export async function getBrowserConsentIfNeeded(): Promise<boolean> {
|
||||
const consentFilePath = path.join(
|
||||
Storage.getGlobalGeminiDir(),
|
||||
BROWSER_PROFILE_DIR,
|
||||
BROWSER_CONSENT_FLAG_FILE,
|
||||
);
|
||||
|
||||
// Fast path: consent already persisted.
|
||||
try {
|
||||
await fs.access(consentFilePath);
|
||||
return true;
|
||||
} catch {
|
||||
// File doesn't exist — need to request consent.
|
||||
void 0;
|
||||
}
|
||||
|
||||
// Non-interactive mode (no UI listeners): auto-accept.
|
||||
if (coreEvents.listenerCount(CoreEvent.ConsentRequest) === 0) {
|
||||
await markConsentAsAcknowledged(consentFilePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
const prompt =
|
||||
'🔒 Browser Agent Privacy Notice\n\n' +
|
||||
'The Browser Agent uses chrome-devtools-mcp to control your browser. ' +
|
||||
'Please note:\n\n' +
|
||||
'• Chrome DevTools MCP collects usage statistics by default ' +
|
||||
'(can be disabled via privacy settings)\n' +
|
||||
'• Performance tools may send trace URLs to Google CrUX API\n' +
|
||||
'• Browser content will be exposed to the AI model for analysis\n' +
|
||||
'• All data is handled per the Google Privacy Policy ' +
|
||||
'(https://policies.google.com/privacy)\n\n' +
|
||||
'Do you understand and consent to proceed?';
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
coreEvents.emitConsentRequest({
|
||||
prompt,
|
||||
onConfirm: async (confirmed: boolean) => {
|
||||
if (confirmed) {
|
||||
await markConsentAsAcknowledged(consentFilePath);
|
||||
}
|
||||
resolve(confirmed);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a sentinel file so consent is not requested again.
|
||||
*/
|
||||
async function markConsentAsAcknowledged(
|
||||
consentFilePath: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(consentFilePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
consentFilePath,
|
||||
`Browser privacy consent acknowledged at ${new Date().toISOString()}\n`,
|
||||
);
|
||||
} catch {
|
||||
// Best-effort: if we can't persist, the dialog will appear again next time.
|
||||
void 0;
|
||||
}
|
||||
}
|
||||
@@ -242,6 +242,50 @@ export abstract class ExtensionLoader {
|
||||
await this.stopExtension(extension);
|
||||
await this.startExtension(extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the most recent session from all extensions if it's within the threshold.
|
||||
*/
|
||||
async getRecentExternalSession(
|
||||
workspaceUri?: string,
|
||||
thresholdMs: number = 10 * 60 * 1000,
|
||||
): Promise<{ prefix: string; id: string; displayName?: string } | null> {
|
||||
const activeExtensions = this.getExtensions().filter((e) => e.isActive);
|
||||
let mostRecent: {
|
||||
prefix: string;
|
||||
id: string;
|
||||
displayName?: string;
|
||||
mtime: number;
|
||||
} | null = null;
|
||||
|
||||
for (const extension of activeExtensions) {
|
||||
if (extension.trajectoryProviderModule) {
|
||||
try {
|
||||
const sessions =
|
||||
await extension.trajectoryProviderModule.listSessions(workspaceUri);
|
||||
for (const s of sessions) {
|
||||
const mtime = new Date(s.mtime).getTime();
|
||||
if (!mostRecent || mtime > mostRecent.mtime) {
|
||||
mostRecent = {
|
||||
prefix: extension.trajectoryProviderModule.prefix || '',
|
||||
id: s.id,
|
||||
displayName: s.displayName,
|
||||
mtime,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore extension errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mostRecent && Date.now() - mostRecent.mtime < thresholdMs) {
|
||||
return mostRecent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExtensionEvents {
|
||||
|
||||
Reference in New Issue
Block a user