Compare commits

...

40 Commits

Author SHA1 Message Date
Spencer 0203ab0a1c Merge branch 'main' into memory_usage3 2026-04-16 13:02:06 -04:00
Sandy Tao 6d7974f1ef feat(core): integrate skill-creator into skill extraction agent (#25421) 2026-04-16 01:38:59 +00:00
gemini-cli-robot 34a9d6e421 Changelog for v0.38.1 (#25476)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-04-16 00:06:43 +00:00
Sandy Tao 00b7781c3c fix(evals): update eval tests for invoke_agent telemetry and project-scoped memory (#25502) 2026-04-15 23:14:12 +00:00
Spencer cdf5c265d8 Merge branch 'main' into memory_usage3 2026-04-15 16:15:59 -04:00
gemini-cli-robot e827cfdf83 Changelog for v0.38.0 (#25470)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-04-15 20:02:53 +00:00
Jason Matthew Suhari cb35ee6710 fix(cli): clean up slash command IDE listeners (#24397)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-04-15 19:41:50 +00:00
Christopher Thomas cb289e0724 fix(core): honor GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL (#25357) 2026-04-15 19:38:38 +00:00
Z1xus c5ad0abb5d feat: add github colorblind themes (#15504)
Co-authored-by: Coco Sheng <cocosheng@google.com>
2026-04-15 19:23:50 +00:00
anj-s e664cc20fe test(evals): add subagent delegation evaluation tests (#24619) 2026-04-15 19:07:29 +00:00
Sandy Tao 485f3d92d8 improve(core): require recurrence evidence before extracting skills (#25147) 2026-04-15 18:45:31 +00:00
cynthialong0-0 5333e5ab20 feat(test): add large conversation scenario for performance test (#25331) 2026-04-15 18:08:43 +00:00
Spencer d0272a6436 Merge branch 'main' into memory_usage3 2026-04-15 11:26:52 -04:00
Spencer d14205a4ce Merge branch 'main' into memory_usage3 2026-04-14 16:31:05 -04:00
Spencer 20ca623cb8 Merge branch 'main' into memory_usage3 2026-04-14 13:31:08 -04:00
Spencer 7ea1654706 test: use rig.log instead of console.error in file stream tests 2026-04-13 22:07:40 +00:00
Spencer 4cde103712 Merge branch 'main' into memory_usage3 2026-04-13 17:57:12 -04:00
Spencer ee635bb0e9 fix(core): remove unused getPathIdentity import in shell.ts 2026-04-13 21:29:52 +00:00
Spencer 7181242f69 Merge branch 'main' into memory_usage3 2026-04-13 16:50:07 -04:00
Spencer 44abbdf56e test(cli): fix duplicate getSessionId in config mock 2026-04-13 20:25:20 +00:00
Spencer 152806379c test(cli): fix flaky InputPrompt SVGs failing in test:ci suite due to styling leaks 2026-04-13 20:25:20 +00:00
Spencer 03efe65dd5 test(cli): update InputPrompt svg snapshots 2026-04-13 20:25:20 +00:00
Spencer 9e3a48a3b6 test(cli): fix flaky InputPrompt snapshot test caused by polling inside waitFor 2026-04-13 20:25:20 +00:00
Spencer e064cfe043 fix(core): ensure childProcessFallback also streams file_data unconditionally without truncation 2026-04-13 20:25:20 +00:00
Spencer 63a6211fe0 fix(core): ensure binary shell output files are still written to disk for 20MB files, and wait for stream close 2026-04-13 20:25:20 +00:00
Spencer c2a17ae257 test: revert to scanning tmpdir because readToolLogs does not expose tool result metadata 2026-04-13 20:25:20 +00:00
Spencer c1297436b9 fix(core): ensure file stream output dir exists and test uses explicit tool output path 2026-04-13 20:25:19 +00:00
Spencer 3d1c2e849c test: fix tests failing due to close event changes in childProcessFallback and add missing config mocks 2026-04-13 20:25:19 +00:00
Spencer cfac19e772 fix: resolve childProcessFallback on close instead of exit to prevent stdout truncation, and fix recursive file search in tests 2026-04-13 20:25:19 +00:00
Spencer ad28dc83c3 test: fix failing tests in core due to shell stream logic changes 2026-04-13 20:25:19 +00:00
Spencer bcd0acae4f fix: preserve fullOutputFilePath when content is summarized and flush pending lines on exit 2026-04-13 20:25:19 +00:00
Spencer 1755678cf9 fix: properly serialize initial AnsiOutput for background processes 2026-04-13 20:25:19 +00:00
Spencer 886025f6b9 chore: finish truncation and stream logging logic 2026-04-13 20:25:19 +00:00
Spencer 7cdfaaa6bd chore: fix truncation logic and test duplications 2026-04-13 20:25:19 +00:00
A.K.M. Adib ea4dd5ae35 lint passes and failing test passes 2026-04-13 20:25:19 +00:00
A.K.M. Adib 8df9a80cc4 bring new functiontruncateOutputIfNeeded without changes to current branch in there 2026-04-13 20:25:19 +00:00
A.K.M. Adib e3baad48c8 tests pass 2026-04-13 20:25:18 +00:00
A.K.M. Adib 0ca2e4e2ae fix build 2026-04-13 20:25:18 +00:00
jacob314 30b4dcecbc Code review fix. 2026-04-13 20:25:18 +00:00
jacob314 954835123f Checkpoint of shell optimization
fix(cli): Write shell command output to a file and limit memory buffered in UI

Fixes.

Checkpoint.

fix(core, cli): await outputStream.end() to prevent race conditions

This commit fixes a critical race condition where
was called synchronously without being awaited. This led to potential file
truncation or EBUSY errors on Windows when attempting to manipulate the file
immediately after the  call.

Additionally, this change removes fixed wait times (`setTimeout`) that
were previously used in test files as a band-aid.

fix(core): stream processed xterm output to file to remove spurious escape codes

test(core): update shell regression tests to use file_data events
2026-04-13 20:25:18 +00:00
45 changed files with 3447 additions and 791 deletions
+21
View File
@@ -18,6 +18,27 @@ on GitHub.
| [Preview](preview.md) | Experimental features ready for early feedback. |
| [Stable](latest.md) | Stable, recommended for general use. |
## Announcements: v0.38.0 - 2026-04-14
- **Chapters Narrative Flow:** Group agent interactions into "Chapters" based on
intent and tool usage for better session structure
([#23150](https://github.com/google-gemini/gemini-cli/pull/23150) by
@Abhijit-2592,
[#24079](https://github.com/google-gemini/gemini-cli/pull/24079) by
@gundermanc).
- **Context Compression Service:** Advanced context management to efficiently
distill conversation history
([#24483](https://github.com/google-gemini/gemini-cli/pull/24483) by
@joshualitt).
- **UI Flicker & UX Enhancements:** Solved rendering flicker with "Terminal
Buffer" mode and introduced selective topic expansion
([#24512](https://github.com/google-gemini/gemini-cli/pull/24512) by
@jacob314, [#24793](https://github.com/google-gemini/gemini-cli/pull/24793) by
@Abhijit-2592).
- **Persistent Policy Approvals:** Implemented context-aware persistent
approvals for tool execution
([#23257](https://github.com/google-gemini/gemini-cli/pull/23257) by @jerop).
## Announcements: v0.37.0 - 2026-04-08
- **Dynamic Sandbox Expansion:** Implemented dynamic sandbox expansion and
+253 -410
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.37.2
# Latest stable release: v0.38.1
Released: April 13, 2026
Released: April 15, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -11,418 +11,261 @@ npm install -g @google/gemini-cli
## Highlights
- **Dynamic Sandbox Expansion:** Implemented dynamic sandbox expansion and
worktree support for both Linux and Windows, enhancing development flexibility
in restricted environments.
- **Tool-Based Topic Grouping (Chapters):** Introduced "Chapters" to logically
group agent interactions based on tool usage and intent, providing a clearer
narrative flow in long sessions.
- **Enhanced Browser Agent:** Added persistent session management, dynamic
read-only tool discovery, and sandbox-aware initialization for the browser
agent.
- **Security & Permission Hardening:** Implemented secret visibility lockdown
for environment files and integrated integrity controls for Windows
sandboxing.
- **Chapters Narrative Flow:** Introduced tool-based topic grouping ("Chapters")
to provide better session structure and narrative continuity in long-running
tasks.
- **Context Compression Service:** Implemented a dedicated service for advanced
context management, efficiently distilling conversation history to preserve
focus and tokens.
- **Enhanced UI Stability & UX:** Introduced a new "Terminal Buffer" mode to
solve rendering flicker, along with selective topic expansion and improved
tool confirmation layouts.
- **Context-Aware Policy Approvals:** Users can now grant persistent,
context-aware approvals for tools, significantly reducing manual confirmation
overhead for trusted workflows.
- **Background Process Monitoring:** New tools for monitoring and inspecting
background shell processes, providing better visibility into asynchronous
tasks.
## What's Changed
- fix(patch): cherry-pick 9d741ab to release/v0.37.1-pr-24565 to patch version
v0.37.1 and create version 0.37.2 by @gemini-cli-robot in
[#25322](https://github.com/google-gemini/gemini-cli/pull/25322)
- fix(acp): handle all InvalidStreamError types gracefully in prompt
[#24540](https://github.com/google-gemini/gemini-cli/pull/24540)
- feat(acp): add support for /about command
[#24649](https://github.com/google-gemini/gemini-cli/pull/24649)
- feat(acp): add /help command
[#24839](https://github.com/google-gemini/gemini-cli/pull/24839)
- feat(evals): centralize test agents into test-utils for reuse by @Samee24 in
[#23616](https://github.com/google-gemini/gemini-cli/pull/23616)
- revert: chore(config): disable agents by default by @abhipatel12 in
[#23672](https://github.com/google-gemini/gemini-cli/pull/23672)
- fix(plan): update telemetry attribute keys and add timestamp by @Adib234 in
[#23685](https://github.com/google-gemini/gemini-cli/pull/23685)
- fix(core): prevent premature MCP discovery completion by @jackwotherspoon in
[#23637](https://github.com/google-gemini/gemini-cli/pull/23637)
- feat(browser): add maxActionsPerTask for browser agent setting by
@cynthialong0-0 in
[#23216](https://github.com/google-gemini/gemini-cli/pull/23216)
- fix(core): improve agent loader error formatting for empty paths by
@adamfweidman in
[#23690](https://github.com/google-gemini/gemini-cli/pull/23690)
- fix(cli): only show updating spinner when auto-update is in progress by
@scidomino in [#23709](https://github.com/google-gemini/gemini-cli/pull/23709)
- Refine onboarding metrics to log the duration explicitly and use the tier
name. by @yunaseoul in
[#23678](https://github.com/google-gemini/gemini-cli/pull/23678)
- chore(tools): add toJSON to tools and invocations to reduce logging verbosity
by @alisa-alisa in
[#22899](https://github.com/google-gemini/gemini-cli/pull/22899)
- fix(cli): stabilize copy mode to prevent flickering and cursor resets by
@mattKorwel in
[#22584](https://github.com/google-gemini/gemini-cli/pull/22584)
- fix(test): move flaky ctrl-c-exit test to non-blocking suite by @mattKorwel in
[#23732](https://github.com/google-gemini/gemini-cli/pull/23732)
- feat(skills): add ci skill for automated failure replication by @mattKorwel in
[#23720](https://github.com/google-gemini/gemini-cli/pull/23720)
- feat(sandbox): implement forbiddenPaths for OS-specific sandbox managers by
@ehedlund in [#23282](https://github.com/google-gemini/gemini-cli/pull/23282)
- fix(core): conditionally expose additional_permissions in shell tool by
@galz10 in [#23729](https://github.com/google-gemini/gemini-cli/pull/23729)
- refactor(core): standardize OS-specific sandbox tests and extract linux helper
methods by @ehedlund in
[#23715](https://github.com/google-gemini/gemini-cli/pull/23715)
- format recently added script by @scidomino in
[#23739](https://github.com/google-gemini/gemini-cli/pull/23739)
- fix(ui): prevent over-eager slash subcommand completion by @keithguerin in
[#20136](https://github.com/google-gemini/gemini-cli/pull/20136)
- Fix dynamic model routing for gemini 3.1 pro to customtools model by
@kevinjwang1 in
[#23641](https://github.com/google-gemini/gemini-cli/pull/23641)
- feat(core): support inline agentCardJson for remote agents by @adamfweidman in
[#23743](https://github.com/google-gemini/gemini-cli/pull/23743)
- fix(cli): skip console log/info in headless mode by @cynthialong0-0 in
[#22739](https://github.com/google-gemini/gemini-cli/pull/22739)
- test(core): install bubblewrap on Linux CI for sandbox integration tests by
@ehedlund in [#23583](https://github.com/google-gemini/gemini-cli/pull/23583)
- docs(reference): split tools table into category sections by @sheikhlimon in
[#21516](https://github.com/google-gemini/gemini-cli/pull/21516)
- fix(browser): detect embedded URLs in query params to prevent allowedDomains
bypass by @tony-shi in
[#23225](https://github.com/google-gemini/gemini-cli/pull/23225)
- fix(browser): add proxy bypass constraint to domain restriction system prompt
by @tony-shi in
[#23229](https://github.com/google-gemini/gemini-cli/pull/23229)
- fix(policy): relax write_file argsPattern in plan mode to allow paths without
session ID by @Adib234 in
[#23695](https://github.com/google-gemini/gemini-cli/pull/23695)
- docs: fix grammar in CONTRIBUTING and numbering in sandbox docs by
@splint-disk-8i in
[#23448](https://github.com/google-gemini/gemini-cli/pull/23448)
- fix(acp): allow attachments by adding a permission prompt by @sripasg in
[#23680](https://github.com/google-gemini/gemini-cli/pull/23680)
- fix(core): thread AbortSignal to chat compression requests (#20405) by
@SH20RAJ in [#20778](https://github.com/google-gemini/gemini-cli/pull/20778)
- feat(core): implement Windows sandbox dynamic expansion Phase 1 and 2.1 by
@scidomino in [#23691](https://github.com/google-gemini/gemini-cli/pull/23691)
- Add note about root privileges in sandbox docs by @diodesign in
[#23314](https://github.com/google-gemini/gemini-cli/pull/23314)
- docs(core): document agent_card_json string literal options for remote agents
by @adamfweidman in
[#23797](https://github.com/google-gemini/gemini-cli/pull/23797)
- fix(cli): resolve TTY hang on headless environments by unconditionally
resuming process.stdin before React Ink launch by @cocosheng-g in
[#23673](https://github.com/google-gemini/gemini-cli/pull/23673)
- fix(ui): cleanup estimated string length hacks in composer by @keithguerin in
[#23694](https://github.com/google-gemini/gemini-cli/pull/23694)
- feat(browser): dynamically discover read-only tools by @cynthialong0-0 in
[#23805](https://github.com/google-gemini/gemini-cli/pull/23805)
- docs: clarify policy requirement for `general.plan.directory` in settings
schema by @jerop in
[#23784](https://github.com/google-gemini/gemini-cli/pull/23784)
- Revert "perf(cli): optimize --version startup time (#23671)" by @scidomino in
[#23812](https://github.com/google-gemini/gemini-cli/pull/23812)
- don't silence errors from wombat by @scidomino in
[#23822](https://github.com/google-gemini/gemini-cli/pull/23822)
- fix(ui): prevent escape key from cancelling requests in shell mode by
@PrasannaPal21 in
[#21245](https://github.com/google-gemini/gemini-cli/pull/21245)
- Changelog for v0.36.0-preview.0 by @gemini-cli-robot in
[#23702](https://github.com/google-gemini/gemini-cli/pull/23702)
- feat(core,ui): Add experiment-gated support for gemini flash 3.1 lite by
@chrstnb in [#23794](https://github.com/google-gemini/gemini-cli/pull/23794)
- Changelog for v0.36.0-preview.3 by @gemini-cli-robot in
[#23827](https://github.com/google-gemini/gemini-cli/pull/23827)
- new linting check: github-actions-pinning by @alisa-alisa in
[#23808](https://github.com/google-gemini/gemini-cli/pull/23808)
- fix(cli): show helpful guidance when no skills are available by @Niralisj in
[#23785](https://github.com/google-gemini/gemini-cli/pull/23785)
- fix: Chat logs and errors handle tail tool calls correctly by @googlestrobe in
[#22460](https://github.com/google-gemini/gemini-cli/pull/22460)
- Don't try removing a tag from a non-existent release. by @scidomino in
[#23830](https://github.com/google-gemini/gemini-cli/pull/23830)
- fix(cli): allow ask question dialog to take full window height by @jacob314 in
[#23693](https://github.com/google-gemini/gemini-cli/pull/23693)
- fix(core): strip leading underscores from error types in telemetry by
@yunaseoul in [#23824](https://github.com/google-gemini/gemini-cli/pull/23824)
- Changelog for v0.35.0 by @gemini-cli-robot in
[#23819](https://github.com/google-gemini/gemini-cli/pull/23819)
- feat(evals): add reliability harvester and 500/503 retry support by
@alisa-alisa in
[#23626](https://github.com/google-gemini/gemini-cli/pull/23626)
- feat(sandbox): dynamic Linux sandbox expansion and worktree support by @galz10
in [#23692](https://github.com/google-gemini/gemini-cli/pull/23692)
- Merge examples of use into quickstart documentation by @diodesign in
[#23319](https://github.com/google-gemini/gemini-cli/pull/23319)
- fix(cli): prioritize primary name matches in slash command search by @sehoon38
in [#23850](https://github.com/google-gemini/gemini-cli/pull/23850)
- Changelog for v0.35.1 by @gemini-cli-robot in
[#23840](https://github.com/google-gemini/gemini-cli/pull/23840)
- fix(browser): keep input blocker active across navigations by @kunal-10-cloud
in [#22562](https://github.com/google-gemini/gemini-cli/pull/22562)
- feat(core): new skill to look for duplicated code while reviewing PRs by
@devr0306 in [#23704](https://github.com/google-gemini/gemini-cli/pull/23704)
- fix(core): replace hardcoded non-interactive ASK_USER denial with explicit
policy rules by @ruomengz in
[#23668](https://github.com/google-gemini/gemini-cli/pull/23668)
- fix(plan): after exiting plan mode switches model to a flash model by @Adib234
in [#23885](https://github.com/google-gemini/gemini-cli/pull/23885)
- feat(gcp): add development worker infrastructure by @mattKorwel in
[#23814](https://github.com/google-gemini/gemini-cli/pull/23814)
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
@kschaab in [#23831](https://github.com/google-gemini/gemini-cli/pull/23831)
- feat(core): define TrajectoryProvider interface by @sehoon38 in
[#23050](https://github.com/google-gemini/gemini-cli/pull/23050)
- Docs: Update quotas and pricing by @jkcinouye in
[#23835](https://github.com/google-gemini/gemini-cli/pull/23835)
- fix(core): allow disabling environment variable redaction by @galz10 in
[#23927](https://github.com/google-gemini/gemini-cli/pull/23927)
- feat(cli): enable notifications cross-platform via terminal bell fallback by
@genneth in [#21618](https://github.com/google-gemini/gemini-cli/pull/21618)
- feat(sandbox): implement secret visibility lockdown for env files by
@DavidAPierce in
[#23712](https://github.com/google-gemini/gemini-cli/pull/23712)
- fix(core): remove shell outputChunks buffer caching to prevent memory bloat
and sanitize prompt input by @spencer426 in
[#23751](https://github.com/google-gemini/gemini-cli/pull/23751)
- feat(core): implement persistent browser session management by @kunal-10-cloud
in [#21306](https://github.com/google-gemini/gemini-cli/pull/21306)
- refactor(core): delegate sandbox denial parsing to SandboxManager by
@scidomino in [#23928](https://github.com/google-gemini/gemini-cli/pull/23928)
- dep(update) Update Ink version to 6.5.0 by @jacob314 in
[#23843](https://github.com/google-gemini/gemini-cli/pull/23843)
- Docs: Update 'docs-writer' skill for relative links by @jkcinouye in
[#21463](https://github.com/google-gemini/gemini-cli/pull/21463)
- Changelog for v0.36.0-preview.4 by @gemini-cli-robot in
[#23935](https://github.com/google-gemini/gemini-cli/pull/23935)
- fix(acp): Update allow approval policy flow for ACP clients to fix config
persistence and compatible with TUI by @sripasg in
[#23818](https://github.com/google-gemini/gemini-cli/pull/23818)
- Changelog for v0.35.2 by @gemini-cli-robot in
[#23960](https://github.com/google-gemini/gemini-cli/pull/23960)
- ACP integration documents by @g-samroberts in
[#22254](https://github.com/google-gemini/gemini-cli/pull/22254)
- fix(core): explicitly set error names to avoid bundling renaming issues by
@yunaseoul in [#23913](https://github.com/google-gemini/gemini-cli/pull/23913)
- feat(core): subagent isolation and cleanup hardening by @abhipatel12 in
[#23903](https://github.com/google-gemini/gemini-cli/pull/23903)
- disable extension-reload test by @scidomino in
[#24018](https://github.com/google-gemini/gemini-cli/pull/24018)
- feat(core): add forbiddenPaths to GlobalSandboxOptions and refactor
createSandboxManager by @ehedlund in
[#23936](https://github.com/google-gemini/gemini-cli/pull/23936)
- refactor(core): improve ignore resolution and fix directory-matching bug by
@ehedlund in [#23816](https://github.com/google-gemini/gemini-cli/pull/23816)
- revert(core): support custom base URL via env vars by @spencer426 in
[#23976](https://github.com/google-gemini/gemini-cli/pull/23976)
- Increase memory limited for eslint. by @jacob314 in
[#24022](https://github.com/google-gemini/gemini-cli/pull/24022)
- fix(acp): prevent crash on empty response in ACP mode by @sripasg in
[#23952](https://github.com/google-gemini/gemini-cli/pull/23952)
- feat(core): Land `AgentHistoryProvider`. by @joshualitt in
[#23978](https://github.com/google-gemini/gemini-cli/pull/23978)
- fix(core): switch to subshells for shell tool wrapping to fix heredocs and
edge cases by @abhipatel12 in
[#24024](https://github.com/google-gemini/gemini-cli/pull/24024)
- Debug command. by @jacob314 in
[#23851](https://github.com/google-gemini/gemini-cli/pull/23851)
- Changelog for v0.36.0-preview.5 by @gemini-cli-robot in
[#24046](https://github.com/google-gemini/gemini-cli/pull/24046)
- Fix test flakes by globally mocking ink-spinner by @jacob314 in
[#24044](https://github.com/google-gemini/gemini-cli/pull/24044)
- Enable network access in sandbox configuration by @galz10 in
[#24055](https://github.com/google-gemini/gemini-cli/pull/24055)
- feat(context): add configurable memoryBoundaryMarkers setting by @SandyTao520
in [#24020](https://github.com/google-gemini/gemini-cli/pull/24020)
- feat(core): implement windows sandbox expansion and denial detection by
@scidomino in [#24027](https://github.com/google-gemini/gemini-cli/pull/24027)
- fix(core): resolve ACP Operation Aborted Errors in grep_search by @ivanporty
in [#23821](https://github.com/google-gemini/gemini-cli/pull/23821)
- fix(hooks): prevent SessionEnd from firing twice in non-interactive mode by
@krishdef7 in [#22139](https://github.com/google-gemini/gemini-cli/pull/22139)
- Re-word intro to Gemini 3 page. by @g-samroberts in
[#24069](https://github.com/google-gemini/gemini-cli/pull/24069)
- fix(cli): resolve layout contention and flashing loop in StatusRow by
@keithguerin in
[#24065](https://github.com/google-gemini/gemini-cli/pull/24065)
- fix(sandbox): implement Windows Mandatory Integrity Control for GeminiSandbox
by @galz10 in [#24057](https://github.com/google-gemini/gemini-cli/pull/24057)
- feat(core): implement tool-based topic grouping (Chapters) by @Abhijit-2592 in
[#23150](https://github.com/google-gemini/gemini-cli/pull/23150)
- feat(cli): support 'tab to queue' for messages while generating by @gundermanc
in [#24052](https://github.com/google-gemini/gemini-cli/pull/24052)
- feat(core): agnostic background task UI with CompletionBehavior by
@adamfweidman in
[#22740](https://github.com/google-gemini/gemini-cli/pull/22740)
- UX for topic narration tool by @gundermanc in
[#24079](https://github.com/google-gemini/gemini-cli/pull/24079)
- fix: shellcheck warnings in scripts by @scidomino in
[#24035](https://github.com/google-gemini/gemini-cli/pull/24035)
- test(evals): add comprehensive subagent delegation evaluations by @abhipatel12
in [#24132](https://github.com/google-gemini/gemini-cli/pull/24132)
- fix(a2a-server): prioritize ADC before evaluating headless constraints for
auth initialization by @spencer426 in
[#23614](https://github.com/google-gemini/gemini-cli/pull/23614)
- Text can be added after /plan command by @rambleraptor in
[#22833](https://github.com/google-gemini/gemini-cli/pull/22833)
- fix(cli): resolve missing F12 logs via global console store by @scidomino in
[#24235](https://github.com/google-gemini/gemini-cli/pull/24235)
- fix broken tests by @scidomino in
[#24279](https://github.com/google-gemini/gemini-cli/pull/24279)
- fix(evals): add update_topic behavioral eval by @gundermanc in
[#24223](https://github.com/google-gemini/gemini-cli/pull/24223)
- feat(core): Unified Context Management and Tool Distillation. by @joshualitt
in [#24157](https://github.com/google-gemini/gemini-cli/pull/24157)
- Default enable narration for the team. by @gundermanc in
[#24224](https://github.com/google-gemini/gemini-cli/pull/24224)
- fix(core): ensure default agents provide tools and use model-specific schemas
by @abhipatel12 in
[#24268](https://github.com/google-gemini/gemini-cli/pull/24268)
- feat(cli): show Flash Lite Preview model regardless of user tier by @sehoon38
in [#23904](https://github.com/google-gemini/gemini-cli/pull/23904)
- feat(cli): implement compact tool output by @jwhelangoog in
[#20974](https://github.com/google-gemini/gemini-cli/pull/20974)
- Add security settings for tool sandboxing by @galz10 in
[#23923](https://github.com/google-gemini/gemini-cli/pull/23923)
- chore(test-utils): switch integration tests to use PREVIEW_GEMINI_MODEL by
@sehoon38 in [#24276](https://github.com/google-gemini/gemini-cli/pull/24276)
- feat(core): enable topic update narration for legacy models by @Abhijit-2592
in [#24241](https://github.com/google-gemini/gemini-cli/pull/24241)
- feat(core): add project-level memory scope to save_memory tool by @SandyTao520
in [#24161](https://github.com/google-gemini/gemini-cli/pull/24161)
- test(integration): fix plan mode write denial test false positive by @sehoon38
in [#24299](https://github.com/google-gemini/gemini-cli/pull/24299)
- feat(plan): support `Plan` mode in untrusted folders by @Adib234 in
[#17586](https://github.com/google-gemini/gemini-cli/pull/17586)
- fix(core): enable mid-stream retries for all models and re-enable compression
test by @sehoon38 in
[#24302](https://github.com/google-gemini/gemini-cli/pull/24302)
- Changelog for v0.36.0-preview.6 by @gemini-cli-robot in
[#24082](https://github.com/google-gemini/gemini-cli/pull/24082)
- Changelog for v0.35.3 by @gemini-cli-robot in
[#24083](https://github.com/google-gemini/gemini-cli/pull/24083)
- feat(cli): add auth info to footer by @sehoon38 in
[#24042](https://github.com/google-gemini/gemini-cli/pull/24042)
- fix(browser): reset action counter for each agent session and let it ignore
internal actions by @cynthialong0-0 in
[#24228](https://github.com/google-gemini/gemini-cli/pull/24228)
- feat(plan): promote planning feature to stable by @ruomengz in
[#24282](https://github.com/google-gemini/gemini-cli/pull/24282)
- fix(browser): terminate subagent immediately on domain restriction violations
by @gsquared94 in
[#24313](https://github.com/google-gemini/gemini-cli/pull/24313)
- feat(cli): add UI to update extensions by @ruomengz in
[#23682](https://github.com/google-gemini/gemini-cli/pull/23682)
- Fix(browser): terminate immediately for "browser is already running" error by
@cynthialong0-0 in
[#24233](https://github.com/google-gemini/gemini-cli/pull/24233)
- docs: Add 'plan' option to approval mode in CLI reference by @YifanRuan in
[#24134](https://github.com/google-gemini/gemini-cli/pull/24134)
- fix(core): batch macOS seatbelt rules into a profile file to prevent ARG_MAX
errors by @ehedlund in
[#24255](https://github.com/google-gemini/gemini-cli/pull/24255)
- fix(core): fix race condition between browser agent and main closing process
by @cynthialong0-0 in
[#24340](https://github.com/google-gemini/gemini-cli/pull/24340)
- perf(build): optimize build scripts for parallel execution and remove
redundant checks by @sehoon38 in
[#24307](https://github.com/google-gemini/gemini-cli/pull/24307)
- ci: install bubblewrap on Linux for release workflows by @ehedlund in
[#24347](https://github.com/google-gemini/gemini-cli/pull/24347)
- chore(release): allow bundling for all builds, including stable by @sehoon38
in [#24305](https://github.com/google-gemini/gemini-cli/pull/24305)
- Revert "Add security settings for tool sandboxing" by @jerop in
[#24357](https://github.com/google-gemini/gemini-cli/pull/24357)
- docs: update subagents docs to not be experimental by @abhipatel12 in
[#24343](https://github.com/google-gemini/gemini-cli/pull/24343)
- fix(core): implement **read and **write commands in sandbox managers by
@galz10 in [#24283](https://github.com/google-gemini/gemini-cli/pull/24283)
- don't try to remove tags in dry run by @scidomino in
[#24356](https://github.com/google-gemini/gemini-cli/pull/24356)
- fix(config): disable JIT context loading by default by @SandyTao520 in
[#24364](https://github.com/google-gemini/gemini-cli/pull/24364)
- test(sandbox): add integration test for dynamic permission expansion by
@galz10 in [#24359](https://github.com/google-gemini/gemini-cli/pull/24359)
- docs(policy): remove unsupported mcpName wildcard edge case by @abhipatel12 in
[#24133](https://github.com/google-gemini/gemini-cli/pull/24133)
- docs: fix broken GEMINI.md link in CONTRIBUTING.md by @Panchal-Tirth in
[#24182](https://github.com/google-gemini/gemini-cli/pull/24182)
- feat(core): infrastructure for event-driven subagent history by @abhipatel12
in [#23914](https://github.com/google-gemini/gemini-cli/pull/23914)
- fix(core): resolve Plan Mode deadlock during plan file creation due to sandbox
restrictions by @DavidAPierce in
[#24047](https://github.com/google-gemini/gemini-cli/pull/24047)
- fix(core): fix browser agent UX issues and improve E2E test reliability by
@gsquared94 in
[#24312](https://github.com/google-gemini/gemini-cli/pull/24312)
- fix(ui): wrap topic and intent fields in TopicMessage by @jwhelangoog in
[#24386](https://github.com/google-gemini/gemini-cli/pull/24386)
- refactor(core): Centralize context management logic into src/context by
@joshualitt in
[#24380](https://github.com/google-gemini/gemini-cli/pull/24380)
- fix(core): pin AuthType.GATEWAY to use Gemini 3.1 Pro/Flash Lite by default by
@sripasg in [#24375](https://github.com/google-gemini/gemini-cli/pull/24375)
- feat(ui): add Tokyo Night theme by @danrneal in
[#24054](https://github.com/google-gemini/gemini-cli/pull/24054)
- fix(cli): refactor test config loading and mock debugLogger in test-setup by
@mattKorwel in
[#24389](https://github.com/google-gemini/gemini-cli/pull/24389)
- Set memoryManager to false in settings.json by @mattKorwel in
[#24393](https://github.com/google-gemini/gemini-cli/pull/24393)
- ink 6.6.3 by @jacob314 in
[#24372](https://github.com/google-gemini/gemini-cli/pull/24372)
- fix(core): resolve subagent chat recording gaps and directory inheritance by
- fix(patch): cherry-pick 050c303 to release/v0.38.0-pr-25317 to patch version
v0.38.0 and create version 0.38.1 by @gemini-cli-robot in
[#25466](https://github.com/google-gemini/gemini-cli/pull/25466)
- fix(cli): refresh slash command list after /skills reload by @NTaylorMullen in
[#24454](https://github.com/google-gemini/gemini-cli/pull/24454)
- Update README.md for links. by @g-samroberts in
[#22759](https://github.com/google-gemini/gemini-cli/pull/22759)
- fix(core): ensure complete_task tool calls are recorded in chat history by
@abhipatel12 in
[#24368](https://github.com/google-gemini/gemini-cli/pull/24368)
- fix(cli): cap shell output at 10 MB to prevent RangeError crash by @ProthamD
in [#24168](https://github.com/google-gemini/gemini-cli/pull/24168)
- feat(plan): conditionally add enter/exit plan mode tools based on current mode
by @ruomengz in
[#24378](https://github.com/google-gemini/gemini-cli/pull/24378)
- feat(core): prioritize discussion before formal plan approval by @jerop in
[#24423](https://github.com/google-gemini/gemini-cli/pull/24423)
- fix(ui): add accelerated scrolling on alternate buffer mode by @devr0306 in
[#23940](https://github.com/google-gemini/gemini-cli/pull/23940)
- feat(core): populate sandbox forbidden paths with project ignore file contents
by @ehedlund in
[#24038](https://github.com/google-gemini/gemini-cli/pull/24038)
- fix(core): ensure blue border overlay and input blocker to act correctly
depending on browser agent activities by @cynthialong0-0 in
[#24385](https://github.com/google-gemini/gemini-cli/pull/24385)
- fix(ui): removed additional vertical padding for tables by @devr0306 in
[#24381](https://github.com/google-gemini/gemini-cli/pull/24381)
- fix(build): upload full bundle directory archive to GitHub releases by
@sehoon38 in [#24403](https://github.com/google-gemini/gemini-cli/pull/24403)
- fix(build): wire bundle:browser-mcp into bundle pipeline by @gsquared94 in
[#24424](https://github.com/google-gemini/gemini-cli/pull/24424)
- feat(browser): add sandbox-aware browser agent initialization by @gsquared94
in [#24419](https://github.com/google-gemini/gemini-cli/pull/24419)
- feat(core): enhance tracker task schemas for detailed titles and descriptions
by @anj-s in [#23902](https://github.com/google-gemini/gemini-cli/pull/23902)
- refactor(core): Unified context management settings schema by @joshualitt in
[#24391](https://github.com/google-gemini/gemini-cli/pull/24391)
- feat(core): update browser agent prompt to check open pages first when
bringing up by @cynthialong0-0 in
[#24431](https://github.com/google-gemini/gemini-cli/pull/24431)
- fix(acp) refactor(core,cli): centralize model discovery logic in
ModelConfigService by @sripasg in
[#24392](https://github.com/google-gemini/gemini-cli/pull/24392)
- Changelog for v0.36.0-preview.7 by @gemini-cli-robot in
[#24346](https://github.com/google-gemini/gemini-cli/pull/24346)
- fix: update task tracker storage location in system prompt by @anj-s in
[#24034](https://github.com/google-gemini/gemini-cli/pull/24034)
- feat(browser): supersede stale snapshots to reclaim context-window tokens by
[#24437](https://github.com/google-gemini/gemini-cli/pull/24437)
- feat(policy): explicitly allow web_fetch in plan mode with ask_user by
@Adib234 in [#24456](https://github.com/google-gemini/gemini-cli/pull/24456)
- fix(core): refactor linux sandbox to fix ARG_MAX crashes by @ehedlund in
[#24286](https://github.com/google-gemini/gemini-cli/pull/24286)
- feat(config): add experimental.adk.agentSessionNoninteractiveEnabled setting
by @adamfweidman in
[#24439](https://github.com/google-gemini/gemini-cli/pull/24439)
- Changelog for v0.36.0-preview.8 by @gemini-cli-robot in
[#24453](https://github.com/google-gemini/gemini-cli/pull/24453)
- feat(cli): change default loadingPhrases to 'off' to hide tips by @keithguerin
in [#24342](https://github.com/google-gemini/gemini-cli/pull/24342)
- fix(cli): ensure agent stops when all declinable tools are cancelled by
@NTaylorMullen in
[#24479](https://github.com/google-gemini/gemini-cli/pull/24479)
- fix(core): enhance sandbox usability and fix build error by @galz10 in
[#24460](https://github.com/google-gemini/gemini-cli/pull/24460)
- Terminal Serializer Optimization by @jacob314 in
[#24485](https://github.com/google-gemini/gemini-cli/pull/24485)
- Auto configure memory. by @jacob314 in
[#24474](https://github.com/google-gemini/gemini-cli/pull/24474)
- Unused error variables in catch block are not allowed by @alisa-alisa in
[#24487](https://github.com/google-gemini/gemini-cli/pull/24487)
- feat(core): add background memory service for skill extraction by @SandyTao520
in [#24274](https://github.com/google-gemini/gemini-cli/pull/24274)
- feat: implement high-signal PR regression check for evaluations by
@alisa-alisa in
[#23937](https://github.com/google-gemini/gemini-cli/pull/23937)
- Fix shell output display by @jacob314 in
[#24490](https://github.com/google-gemini/gemini-cli/pull/24490)
- fix(ui): resolve unwanted vertical spacing around various tool output
treatments by @jwhelangoog in
[#24449](https://github.com/google-gemini/gemini-cli/pull/24449)
- revert(cli): bring back input box and footer visibility in copy mode by
@sehoon38 in [#24504](https://github.com/google-gemini/gemini-cli/pull/24504)
- fix(cli): prevent crash in AnsiOutputText when handling non-array data by
@sehoon38 in [#24498](https://github.com/google-gemini/gemini-cli/pull/24498)
- feat(cli): support default values for environment variables by @ruomengz in
[#24469](https://github.com/google-gemini/gemini-cli/pull/24469)
- Implement background process monitoring and inspection tools by @cocosheng-g
in [#23799](https://github.com/google-gemini/gemini-cli/pull/23799)
- docs(browser-agent): update stale browser agent documentation by @gsquared94
in [#24463](https://github.com/google-gemini/gemini-cli/pull/24463)
- fix: enable browser_agent in integration tests and add localhost fixture tests
by @gsquared94 in
[#24523](https://github.com/google-gemini/gemini-cli/pull/24523)
- fix(browser): handle computer-use model detection for analyze_screenshot by
@gsquared94 in
[#24440](https://github.com/google-gemini/gemini-cli/pull/24440)
- docs(core): add subagent tool isolation draft doc by @akh64bit in
[#23275](https://github.com/google-gemini/gemini-cli/pull/23275)
- fix(patch): cherry-pick 64c928f to release/v0.37.0-preview.0-pr-23257 to patch
version v0.37.0-preview.0 and create version 0.37.0-preview.1 by
@gemini-cli-robot in
[#24561](https://github.com/google-gemini/gemini-cli/pull/24561)
- fix(patch): cherry-pick cb7f7d6 to release/v0.37.0-preview.1-pr-24342 to patch
version v0.37.0-preview.1 and create version 0.37.0-preview.2 by
@gemini-cli-robot in
[#24842](https://github.com/google-gemini/gemini-cli/pull/24842)
[#24502](https://github.com/google-gemini/gemini-cli/pull/24502)
- feat(core): Land ContextCompressionService by @joshualitt in
[#24483](https://github.com/google-gemini/gemini-cli/pull/24483)
- feat(core): scope subagent workspace directories via AsyncLocalStorage by
@SandyTao520 in
[#24445](https://github.com/google-gemini/gemini-cli/pull/24445)
- Update ink version to 6.6.7 by @jacob314 in
[#24514](https://github.com/google-gemini/gemini-cli/pull/24514)
- fix(acp): handle all InvalidStreamError types gracefully in prompt by @sripasg
in [#24540](https://github.com/google-gemini/gemini-cli/pull/24540)
- Fix crash when vim editor is not found in PATH on Windows by
@Nagajyothi-tammisetti in
[#22423](https://github.com/google-gemini/gemini-cli/pull/22423)
- fix(core): move project memory dir under tmp directory by @SandyTao520 in
[#24542](https://github.com/google-gemini/gemini-cli/pull/24542)
- Enable 'Other' option for yesno question type by @ruomengz in
[#24545](https://github.com/google-gemini/gemini-cli/pull/24545)
- fix(cli): clear stale retry/loading state after cancellation (#21096) by
@Aaxhirrr in [#21960](https://github.com/google-gemini/gemini-cli/pull/21960)
- Changelog for v0.37.0-preview.0 by @gemini-cli-robot in
[#24464](https://github.com/google-gemini/gemini-cli/pull/24464)
- feat(core): implement context-aware persistent policy approvals by @jerop in
[#23257](https://github.com/google-gemini/gemini-cli/pull/23257)
- docs: move agent disabling instructions and update remote agent status by
@jackwotherspoon in
[#24559](https://github.com/google-gemini/gemini-cli/pull/24559)
- feat(cli): migrate nonInteractiveCli to LegacyAgentSession by @adamfweidman in
[#22987](https://github.com/google-gemini/gemini-cli/pull/22987)
- fix(core): unsafe type assertions in Core File System #19712 by
@aniketsaurav18 in
[#19739](https://github.com/google-gemini/gemini-cli/pull/19739)
- fix(ui): hide model quota in /stats and refactor quota display by @danzaharia1
in [#24206](https://github.com/google-gemini/gemini-cli/pull/24206)
- Changelog for v0.36.0 by @gemini-cli-robot in
[#24558](https://github.com/google-gemini/gemini-cli/pull/24558)
- Changelog for v0.37.0-preview.1 by @gemini-cli-robot in
[#24568](https://github.com/google-gemini/gemini-cli/pull/24568)
- docs: add missing .md extensions to internal doc links by @ishaan-arora-1 in
[#24145](https://github.com/google-gemini/gemini-cli/pull/24145)
- fix(ui): fixed table styling by @devr0306 in
[#24565](https://github.com/google-gemini/gemini-cli/pull/24565)
- fix(core): pass includeDirectories to sandbox configuration by @galz10 in
[#24573](https://github.com/google-gemini/gemini-cli/pull/24573)
- feat(ui): enable "TerminalBuffer" mode to solve flicker by @jacob314 in
[#24512](https://github.com/google-gemini/gemini-cli/pull/24512)
- docs: clarify release coordination by @scidomino in
[#24575](https://github.com/google-gemini/gemini-cli/pull/24575)
- fix(core): remove broken PowerShell translation and fix native \_\_write in
Windows sandbox by @scidomino in
[#24571](https://github.com/google-gemini/gemini-cli/pull/24571)
- Add instructions for how to start react in prod and force react to prod mode
by @jacob314 in
[#24590](https://github.com/google-gemini/gemini-cli/pull/24590)
- feat(cli): minimalist sandbox status labels by @galz10 in
[#24582](https://github.com/google-gemini/gemini-cli/pull/24582)
- Feat/browser agent metrics by @kunal-10-cloud in
[#24210](https://github.com/google-gemini/gemini-cli/pull/24210)
- test: fix Windows CI execution and resolve exposed platform failures by
@ehedlund in [#24476](https://github.com/google-gemini/gemini-cli/pull/24476)
- feat(core,cli): prioritize summary for topics (#24608) by @Abhijit-2592 in
[#24609](https://github.com/google-gemini/gemini-cli/pull/24609)
- show color by @jacob314 in
[#24613](https://github.com/google-gemini/gemini-cli/pull/24613)
- feat(cli): enable compact tool output by default (#24509) by @jwhelangoog in
[#24510](https://github.com/google-gemini/gemini-cli/pull/24510)
- fix(core): inject skill system instructions into subagent prompts if activated
by @abhipatel12 in
[#24620](https://github.com/google-gemini/gemini-cli/pull/24620)
- fix(core): improve windows sandbox reliability and fix integration tests by
@ehedlund in [#24480](https://github.com/google-gemini/gemini-cli/pull/24480)
- fix(core): ensure sandbox approvals are correctly persisted and matched for
proactive expansions by @galz10 in
[#24577](https://github.com/google-gemini/gemini-cli/pull/24577)
- feat(cli) Scrollbar for input prompt by @jacob314 in
[#21992](https://github.com/google-gemini/gemini-cli/pull/21992)
- Do not run pr-eval workflow when no steering changes detected by @alisa-alisa
in [#24621](https://github.com/google-gemini/gemini-cli/pull/24621)
- Fix restoration of topic headers. by @gundermanc in
[#24650](https://github.com/google-gemini/gemini-cli/pull/24650)
- feat(core): discourage update topic tool for simple tasks by @Samee24 in
[#24640](https://github.com/google-gemini/gemini-cli/pull/24640)
- fix(core): ensure global temp directory is always in sandbox allowed paths by
@galz10 in [#24638](https://github.com/google-gemini/gemini-cli/pull/24638)
- fix(core): detect uninitialized lines by @jacob314 in
[#24646](https://github.com/google-gemini/gemini-cli/pull/24646)
- docs: update sandboxing documentation and toolSandboxing settings by @galz10
in [#24655](https://github.com/google-gemini/gemini-cli/pull/24655)
- feat(cli): enhance tool confirmation UI and selection layout by @galz10 in
[#24376](https://github.com/google-gemini/gemini-cli/pull/24376)
- feat(acp): add support for `/about` command by @sripasg in
[#24649](https://github.com/google-gemini/gemini-cli/pull/24649)
- feat(cli): add role specific metrics to /stats by @cynthialong0-0 in
[#24659](https://github.com/google-gemini/gemini-cli/pull/24659)
- split context by @jacob314 in
[#24623](https://github.com/google-gemini/gemini-cli/pull/24623)
- fix(cli): remove -S from shebang to fix Windows and BSD execution by
@scidomino in [#24756](https://github.com/google-gemini/gemini-cli/pull/24756)
- Fix issue where topic headers can be posted back to back by @gundermanc in
[#24759](https://github.com/google-gemini/gemini-cli/pull/24759)
- fix(core): handle partial llm_request in BeforeModel hook override by
@krishdef7 in [#22326](https://github.com/google-gemini/gemini-cli/pull/22326)
- fix(ui): improve narration suppression and reduce flicker by @gundermanc in
[#24635](https://github.com/google-gemini/gemini-cli/pull/24635)
- fix(ui): fixed auth race condition causing logo to flicker by @devr0306 in
[#24652](https://github.com/google-gemini/gemini-cli/pull/24652)
- fix(browser): remove premature browser cleanup after subagent invocation by
@gsquared94 in
[#24753](https://github.com/google-gemini/gemini-cli/pull/24753)
- Revert "feat(core,cli): prioritize summary for topics (#24608)" by
@Abhijit-2592 in
[#24777](https://github.com/google-gemini/gemini-cli/pull/24777)
- relax tool sandboxing overrides for plan mode to match defaults. by
@DavidAPierce in
[#24762](https://github.com/google-gemini/gemini-cli/pull/24762)
- fix(cli): respect global environment variable allowlist by @scidomino in
[#24767](https://github.com/google-gemini/gemini-cli/pull/24767)
- fix(cli): ensure skills list outputs to stdout in non-interactive environments
by @spencer426 in
[#24566](https://github.com/google-gemini/gemini-cli/pull/24566)
- Add an eval for and fix unsafe cloning behavior. by @gundermanc in
[#24457](https://github.com/google-gemini/gemini-cli/pull/24457)
- fix(policy): allow complete_task in plan mode by @abhipatel12 in
[#24771](https://github.com/google-gemini/gemini-cli/pull/24771)
- feat(telemetry): add browser agent clearcut metrics by @gsquared94 in
[#24688](https://github.com/google-gemini/gemini-cli/pull/24688)
- feat(cli): support selective topic expansion and click-to-expand by
@Abhijit-2592 in
[#24793](https://github.com/google-gemini/gemini-cli/pull/24793)
- temporarily disable sandbox integration test on windows by @ehedlund in
[#24786](https://github.com/google-gemini/gemini-cli/pull/24786)
- Remove flakey test by @scidomino in
[#24837](https://github.com/google-gemini/gemini-cli/pull/24837)
- Alisa/approve button by @alisa-alisa in
[#24645](https://github.com/google-gemini/gemini-cli/pull/24645)
- feat(hooks): display hook system messages in UI by @mbleigh in
[#24616](https://github.com/google-gemini/gemini-cli/pull/24616)
- fix(core): propagate BeforeModel hook model override end-to-end by @krishdef7
in [#24784](https://github.com/google-gemini/gemini-cli/pull/24784)
- chore: fix formatting for behavioral eval skill reference file by @abhipatel12
in [#24846](https://github.com/google-gemini/gemini-cli/pull/24846)
- fix: use directory junctions on Windows for skill linking by @enjoykumawat in
[#24823](https://github.com/google-gemini/gemini-cli/pull/24823)
- fix(cli): prevent multiple banner increments on remount by @sehoon38 in
[#24843](https://github.com/google-gemini/gemini-cli/pull/24843)
- feat(acp): add /help command by @sripasg in
[#24839](https://github.com/google-gemini/gemini-cli/pull/24839)
- fix(core): remove tmux alternate buffer warning by @jackwotherspoon in
[#24852](https://github.com/google-gemini/gemini-cli/pull/24852)
- Improve sandbox error matching and caching by @DavidAPierce in
[#24550](https://github.com/google-gemini/gemini-cli/pull/24550)
- feat(core): add agent protocol UI types and experimental flag by @mbleigh in
[#24275](https://github.com/google-gemini/gemini-cli/pull/24275)
- feat(core): use experiment flags for default fetch timeouts by @yunaseoul in
[#24261](https://github.com/google-gemini/gemini-cli/pull/24261)
- Revert "fix(ui): improve narration suppression and reduce flicker (#2… by
@gundermanc in
[#24857](https://github.com/google-gemini/gemini-cli/pull/24857)
- refactor(cli): remove duplication in interactive shell awaiting input hint by
@JayadityaGit in
[#24801](https://github.com/google-gemini/gemini-cli/pull/24801)
- refactor(core): make LegacyAgentSession dependencies optional by @mbleigh in
[#24287](https://github.com/google-gemini/gemini-cli/pull/24287)
- Changelog for v0.37.0-preview.2 by @gemini-cli-robot in
[#24848](https://github.com/google-gemini/gemini-cli/pull/24848)
- fix(cli): always show shell command description or actual command by @jacob314
in [#24774](https://github.com/google-gemini/gemini-cli/pull/24774)
- Added flag for ept size and increased default size by @devr0306 in
[#24859](https://github.com/google-gemini/gemini-cli/pull/24859)
- fix(core): dispose Scheduler to prevent McpProgress listener leak by
@Anjaligarhwal in
[#24870](https://github.com/google-gemini/gemini-cli/pull/24870)
- fix(cli): switch default back to terminalBuffer=false and fix regressions
introduced for that mode by @jacob314 in
[#24873](https://github.com/google-gemini/gemini-cli/pull/24873)
- feat(cli): switch to ctrl+g from ctrl-x by @jacob314 in
[#24861](https://github.com/google-gemini/gemini-cli/pull/24861)
- fix: isolate concurrent browser agent instances by @gsquared94 in
[#24794](https://github.com/google-gemini/gemini-cli/pull/24794)
- docs: update MCP server OAuth redirect port documentation by @adamfweidman in
[#24844](https://github.com/google-gemini/gemini-cli/pull/24844)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.36.0...v0.37.2
https://github.com/google-gemini/gemini-cli/compare/v0.38.0...v0.38.1
+15
View File
@@ -2148,6 +2148,21 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- When set, overrides the default API version used by the SDK.
- Example: `export GOOGLE_GENAI_API_VERSION="v1"` (Windows PowerShell:
`$env:GOOGLE_GENAI_API_VERSION="v1"`)
- **`GOOGLE_GEMINI_BASE_URL`**:
- Overrides the default base URL for Gemini API requests (when using
`gemini-api-key` authentication).
- Must be a valid URL. For security, it must use HTTPS unless pointing to
`localhost` (or `127.0.0.1` / `[::1]`).
- Example: `export GOOGLE_GEMINI_BASE_URL="https://my-proxy.com"` (Windows
PowerShell: `$env:GOOGLE_GEMINI_BASE_URL="https://my-proxy.com"`)
- **`GOOGLE_VERTEX_BASE_URL`**:
- Overrides the default base URL for Vertex AI API requests (when using
`vertex-ai` authentication).
- Must be a valid URL. For security, it must use HTTPS unless pointing to
`localhost` (or `127.0.0.1` / `[::1]`).
- Example: `export GOOGLE_VERTEX_BASE_URL="https://my-vertex-proxy.com"`
(Windows PowerShell:
`$env:GOOGLE_VERTEX_BASE_URL="https://my-vertex-proxy.com"`)
- **`OTLP_GOOGLE_CLOUD_PROJECT`**:
- Your Google Cloud Project ID for Telemetry in Google Cloud
- Example: `export OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"` (Windows
+11 -3
View File
@@ -17,9 +17,17 @@ describe('CliHelpAgent Delegation', () => {
timeout: 60000,
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs();
const toolCallIndex = toolLogs.findIndex(
(log) => log.toolRequest.name === 'cli_help',
);
const toolCallIndex = toolLogs.findIndex((log) => {
if (log.toolRequest.name === 'invoke_agent') {
try {
const args = JSON.parse(log.toolRequest.args);
return args.agent_name === 'cli_help';
} catch {
return false;
}
}
return false;
});
expect(toolCallIndex).toBeGreaterThan(-1);
expect(toolCallIndex).toBeLessThan(5); // Called within first 5 turns
},
+17 -1
View File
@@ -16,6 +16,7 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { randomUUID } from 'node:crypto';
import { vi } from 'vitest';
import {
Config,
type ConfigParameters,
@@ -52,6 +53,7 @@ export interface ComponentEvalCase extends BaseEvalCase {
export class ComponentRig {
public config: Config | undefined;
public testDir: string;
public homeDir: string;
public sessionId: string;
constructor(
@@ -61,6 +63,9 @@ export class ComponentRig {
this.testDir = fs.mkdtempSync(
path.join(os.tmpdir(), `gemini-component-rig-${uniqueId.slice(0, 8)}-`),
);
this.homeDir = fs.mkdtempSync(
path.join(os.tmpdir(), `gemini-component-home-${uniqueId.slice(0, 8)}-`),
);
this.sessionId = `test-session-${uniqueId}`;
}
@@ -89,12 +94,23 @@ export class ComponentRig {
this.config = makeFakeConfig(configParams);
await this.config.initialize();
// Refresh auth using USE_GEMINI to initialize the real BaseLlmClient
// Refresh auth using USE_GEMINI to initialize the real BaseLlmClient.
// This must happen BEFORE stubbing GEMINI_CLI_HOME because OAuth credential
// lookup resolves through homedir() → GEMINI_CLI_HOME.
await this.config.refreshAuth(AuthType.USE_GEMINI);
// Isolate storage paths (session files, skills, extraction state) by
// pointing GEMINI_CLI_HOME at a per-test temp directory. Storage resolves
// global paths through `homedir()` which reads this env var. This is set
// after auth so credential lookup uses the real home directory.
vi.stubEnv('GEMINI_CLI_HOME', this.homeDir);
}
async cleanup() {
await this.config?.dispose();
vi.unstubAllEnvs();
fs.rmSync(this.testDir, { recursive: true, force: true });
fs.rmSync(this.homeDir, { recursive: true, force: true });
}
}
+14 -3
View File
@@ -26,11 +26,22 @@ describe('generalist_agent', () => {
prompt:
'Please use the generalist agent to create a file called "generalist_test_file.txt" containing exactly the following text: success',
assert: async (rig) => {
// 1) Verify the generalist agent was invoked
const foundToolCall = await rig.waitForToolCall('generalist');
// 1) Verify the generalist agent was invoked via invoke_agent
const foundToolCall = await rig.waitForToolCall(
'invoke_agent',
undefined,
(args) => {
try {
const parsed = JSON.parse(args);
return parsed.agent_name === 'generalist';
} catch {
return false;
}
},
);
expect(
foundToolCall,
'Expected to find a tool call for generalist agent',
'Expected to find an invoke_agent tool call for generalist agent',
).toBeTruthy();
// 2) Verify the file was created as expected
+63 -31
View File
@@ -145,22 +145,30 @@ describe('save_memory', () => {
},
});
const ignoringDbSchemaLocation =
"Agent ignores workspace's database schema location";
const savingDbSchemaLocationAsProjectMemory =
'Agent saves workspace database schema location as project memory';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: ignoringDbSchemaLocation,
name: savingDbSchemaLocationAsProjectMemory,
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
const wasToolCalled = rig
.readToolLogs()
.some((log) => log.toolRequest.name === 'save_memory');
const wasToolCalled = await rig.waitForToolCall(
'save_memory',
undefined,
(args) => {
try {
const params = JSON.parse(args);
return params.scope === 'project';
} catch {
return false;
}
},
);
expect(
wasToolCalled,
'save_memory should not be called for workspace-specific information',
).toBe(false);
'Expected save_memory to be called with scope="project" for workspace-specific information',
).toBe(true);
assertModelHasOutput(result);
},
@@ -188,42 +196,59 @@ describe('save_memory', () => {
},
});
const ignoringBuildArtifactLocation =
'Agent ignores workspace build artifact location';
const savingBuildArtifactLocationAsProjectMemory =
'Agent saves workspace build artifact location as project memory';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: ignoringBuildArtifactLocation,
name: savingBuildArtifactLocationAsProjectMemory,
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
const wasToolCalled = rig
.readToolLogs()
.some((log) => log.toolRequest.name === 'save_memory');
const wasToolCalled = await rig.waitForToolCall(
'save_memory',
undefined,
(args) => {
try {
const params = JSON.parse(args);
return params.scope === 'project';
} catch {
return false;
}
},
);
expect(
wasToolCalled,
'save_memory should not be called for workspace-specific information',
).toBe(false);
'Expected save_memory to be called with scope="project" for workspace-specific information',
).toBe(true);
assertModelHasOutput(result);
},
});
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
const savingMainEntryPointAsProjectMemory =
'Agent saves workspace main entry point as project memory';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: ignoringMainEntryPoint,
name: savingMainEntryPointAsProjectMemory,
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
const wasToolCalled = rig
.readToolLogs()
.some((log) => log.toolRequest.name === 'save_memory');
const wasToolCalled = await rig.waitForToolCall(
'save_memory',
undefined,
(args) => {
try {
const params = JSON.parse(args);
return params.scope === 'project';
} catch {
return false;
}
},
);
expect(
wasToolCalled,
'save_memory should not be called for workspace-specific information',
).toBe(false);
'Expected save_memory to be called with scope="project" for workspace-specific information',
).toBe(true);
assertModelHasOutput(result);
},
@@ -317,13 +342,13 @@ describe('save_memory', () => {
'Please save any persistent preferences or facts about me from our conversation to memory.',
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall(
'save_memory',
'invoke_agent',
undefined,
(args) => /vitest/i.test(args),
(args) => /save_memory/i.test(args) && /vitest/i.test(args),
);
expect(
wasToolCalled,
'Expected save_memory to be called with the Vitest preference from the conversation history',
'Expected invoke_agent to be called with save_memory agent and the Vitest preference from the conversation history',
).toBe(true);
assertModelHasOutput(result);
@@ -379,8 +404,15 @@ describe('save_memory', () => {
],
prompt: 'Please save the preferences I mentioned earlier to memory.',
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
expect(wasToolCalled, 'Expected save_memory to be called').toBe(true);
const wasToolCalled = await rig.waitForToolCall(
'invoke_agent',
undefined,
(args) => /save_memory/i.test(args),
);
expect(
wasToolCalled,
'Expected invoke_agent to be called with save_memory agent',
).toBe(true);
assertModelHasOutput(result);
},
+349
View File
@@ -0,0 +1,349 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fsp from 'node:fs/promises';
import path from 'node:path';
import { describe, expect } from 'vitest';
import {
type Config,
ApprovalMode,
SESSION_FILE_PREFIX,
getProjectHash,
startMemoryService,
} from '@google/gemini-cli-core';
import { componentEvalTest } from './component-test-helper.js';
interface SeedSession {
sessionId: string;
summary: string;
userTurns: string[];
timestampOffsetMinutes: number;
}
interface MessageRecord {
id: string;
timestamp: string;
type: string;
content: Array<{ text: string }>;
}
const WORKSPACE_FILES = {
'package.json': JSON.stringify(
{
name: 'skill-extraction-eval',
private: true,
scripts: {
build: 'echo build',
lint: 'echo lint',
test: 'echo test',
},
},
null,
2,
),
'README.md': `# Skill Extraction Eval
This workspace exists to exercise background skill extraction from prior chats.
`,
};
function buildMessages(userTurns: string[]): MessageRecord[] {
const baseTime = new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString();
return userTurns.flatMap((text, index) => [
{
id: `u${index + 1}`,
timestamp: baseTime,
type: 'user',
content: [{ text }],
},
{
id: `a${index + 1}`,
timestamp: baseTime,
type: 'gemini',
content: [{ text: `Acknowledged: ${index + 1}` }],
},
]);
}
async function seedSessions(
config: Config,
sessions: SeedSession[],
): Promise<void> {
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
await fsp.mkdir(chatsDir, { recursive: true });
const projectRoot = config.storage.getProjectRoot();
for (const session of sessions) {
const timestamp = new Date(
Date.now() - session.timestampOffsetMinutes * 60 * 1000,
)
.toISOString()
.slice(0, 16)
.replace(/:/g, '-');
const filename = `${SESSION_FILE_PREFIX}${timestamp}-${session.sessionId.slice(0, 8)}.json`;
const conversation = {
sessionId: session.sessionId,
projectHash: getProjectHash(projectRoot),
summary: session.summary,
startTime: new Date(Date.now() - 7 * 60 * 60 * 1000).toISOString(),
lastUpdated: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
messages: buildMessages(session.userTurns),
};
await fsp.writeFile(
path.join(chatsDir, filename),
JSON.stringify(conversation, null, 2),
);
}
}
async function runExtractionAndReadState(config: Config): Promise<{
state: { runs: Array<{ sessionIds: string[]; skillsCreated: string[] }> };
skillsDir: string;
}> {
await startMemoryService(config);
const memoryDir = config.storage.getProjectMemoryTempDir();
const skillsDir = config.storage.getProjectSkillsMemoryDir();
const statePath = path.join(memoryDir, '.extraction-state.json');
const raw = await fsp.readFile(statePath, 'utf-8');
const state = JSON.parse(raw) as {
runs?: Array<{ sessionIds?: string[]; skillsCreated?: string[] }>;
};
if (!Array.isArray(state.runs) || state.runs.length === 0) {
throw new Error('Skill extraction finished without writing any run state');
}
return {
state: {
runs: state.runs.map((run) => ({
sessionIds: Array.isArray(run.sessionIds) ? run.sessionIds : [],
skillsCreated: Array.isArray(run.skillsCreated)
? run.skillsCreated
: [],
})),
},
skillsDir,
};
}
async function readSkillBodies(skillsDir: string): Promise<string[]> {
try {
const entries = await fsp.readdir(skillsDir, { withFileTypes: true });
const skillDirs = entries.filter((entry) => entry.isDirectory());
const bodies = await Promise.all(
skillDirs.map((entry) =>
fsp.readFile(path.join(skillsDir, entry.name, 'SKILL.md'), 'utf-8'),
),
);
return bodies;
} catch {
return [];
}
}
/**
* Shared configOverrides for all skill extraction component evals.
* - experimentalMemoryManager: enables the memory extraction pipeline.
* - approvalMode: YOLO auto-approves tool calls (write_file, read_file) so the
* background agent can execute without interactive confirmation.
*/
const EXTRACTION_CONFIG_OVERRIDES = {
experimentalMemoryManager: true,
approvalMode: ApprovalMode.YOLO,
};
describe('Skill Extraction', () => {
componentEvalTest('USUALLY_PASSES', {
suiteName: 'skill-extraction',
suiteType: 'component-level',
name: 'ignores one-off incidents even when session summaries look similar',
files: WORKSPACE_FILES,
timeout: 180000,
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
setup: async (config) => {
await seedSessions(config, [
{
sessionId: 'incident-login-redirect',
summary: 'Debug login redirect loop in staging',
timestampOffsetMinutes: 420,
userTurns: [
'We only need a one-off fix for incident INC-4412 on branch hotfix/login-loop.',
'The exact failing string is ERR_REDIRECT_4412 and this workaround is incident-specific.',
'Patch packages/auth/src/redirect.ts just for this branch and do not generalize it.',
'The thing that worked was deleting the stale staging cookie before retrying.',
'This is not a normal workflow and should not become a reusable instruction.',
'It only reproduced against the 2026-04-08 staging rollout.',
'After the cookie clear, the branch-specific redirect logic passed.',
'Do not turn this incident writeup into a standing process.',
'Yes, the hotfix worked for this exact redirect-loop incident.',
'Close out INC-4412 once the staging login succeeds again.',
],
},
{
sessionId: 'incident-login-timeout',
summary: 'Debug login callback timeout in staging',
timestampOffsetMinutes: 360,
userTurns: [
'This is another one-off staging incident, this time TICKET-991 for callback timeout.',
'The exact failing string is ERR_CALLBACK_TIMEOUT_991 and it is unrelated to the redirect loop.',
'The temporary fix was rotating the staging secret and deleting a bad feature-flag row.',
'Do not write a generic login-debugging playbook from this.',
'This only applied to the callback timeout during the April rollout.',
'The successful fix was specific to the stale secret in staging.',
'It does not define a durable repo workflow for future tasks.',
'After rotating the secret, the callback timeout stopped reproducing.',
'Treat this as incident response only, not a reusable skill.',
'Once staging passed again, we closed TICKET-991.',
],
},
]);
},
assert: async (config) => {
const { state, skillsDir } = await runExtractionAndReadState(config);
const skillBodies = await readSkillBodies(skillsDir);
expect(state.runs).toHaveLength(1);
expect(state.runs[0].sessionIds).toHaveLength(2);
expect(state.runs[0].skillsCreated).toEqual([]);
expect(skillBodies).toEqual([]);
},
});
componentEvalTest('USUALLY_PASSES', {
suiteName: 'skill-extraction',
suiteType: 'component-level',
name: 'extracts a repeated project-specific workflow into a skill',
files: WORKSPACE_FILES,
timeout: 180000,
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
setup: async (config) => {
await seedSessions(config, [
{
sessionId: 'settings-docs-regen-1',
summary: 'Update settings docs after adding a config option',
timestampOffsetMinutes: 420,
userTurns: [
'When we add a new config option, we have to regenerate the settings docs in a specific order.',
'The sequence that worked was npm run predocs:settings, npm run schema:settings, then npm run docs:settings.',
'Do not hand-edit generated settings docs.',
'If predocs is skipped, the generated schema docs miss the new defaults.',
'Update the source first, then run that generation sequence.',
'After regenerating, verify the schema output and docs changed together.',
'We used this same sequence the last time we touched settings docs.',
'That ordered workflow passed and produced the expected generated files.',
'Please keep the exact command order because reversing it breaks the output.',
'Yes, the generated settings docs were correct after those three commands.',
],
},
{
sessionId: 'settings-docs-regen-2',
summary: 'Regenerate settings schema docs for another new setting',
timestampOffsetMinutes: 360,
userTurns: [
'We are touching another setting, so follow the same settings-doc regeneration workflow again.',
'Run npm run predocs:settings before npm run schema:settings and npm run docs:settings.',
'The project keeps generated settings docs in sync through those commands, not manual edits.',
'Skipping predocs caused stale defaults in the generated output before.',
'Change the source, then execute the same three commands in order.',
'Verify both the schema artifact and docs update together after regeneration.',
'This is the recurring workflow we use whenever a setting changes.',
'The exact order worked again on this second settings update.',
'Please preserve that ordering constraint for future settings changes.',
'Confirmed: the settings docs regenerated correctly with the same command sequence.',
],
},
]);
},
assert: async (config) => {
const { state, skillsDir } = await runExtractionAndReadState(config);
const skillBodies = await readSkillBodies(skillsDir);
const combinedSkills = skillBodies.join('\n\n');
expect(state.runs).toHaveLength(1);
expect(state.runs[0].sessionIds).toHaveLength(2);
expect(state.runs[0].skillsCreated.length).toBeGreaterThanOrEqual(1);
expect(skillBodies.length).toBeGreaterThanOrEqual(1);
expect(combinedSkills).toContain('npm run predocs:settings');
expect(combinedSkills).toContain('npm run schema:settings');
expect(combinedSkills).toContain('npm run docs:settings');
expect(combinedSkills).toMatch(/Verification/i);
// Verify the extraction agent activated skill-creator for design guidance.
expect(config.getSkillManager().isSkillActive('skill-creator')).toBe(
true,
);
},
});
componentEvalTest('USUALLY_PASSES', {
suiteName: 'skill-extraction',
suiteType: 'component-level',
name: 'extracts a repeated multi-step migration workflow with ordering constraints',
files: WORKSPACE_FILES,
timeout: 180000,
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
setup: async (config) => {
await seedSessions(config, [
{
sessionId: 'db-migration-v12',
summary: 'Run database migration for v12 schema update',
timestampOffsetMinutes: 420,
userTurns: [
'Every time we change the database schema we follow a specific migration workflow.',
'First run npm run db:check to verify no pending migrations conflict.',
'Then run npm run db:migrate to apply the new migration files.',
'After migration, always run npm run db:validate to confirm schema integrity.',
'If db:validate fails, immediately run npm run db:rollback before anything else.',
'Never skip db:check — last time we did, two migrations collided and corrupted the index.',
'The ordering is critical: check, migrate, validate. Reversing migrate and validate caused silent data loss before.',
'This v12 migration passed after following that exact sequence.',
'We use this same three-step workflow every time the schema changes.',
'Confirmed: db:check, db:migrate, db:validate completed successfully for v12.',
],
},
{
sessionId: 'db-migration-v13',
summary: 'Run database migration for v13 schema update',
timestampOffsetMinutes: 360,
userTurns: [
'New schema change for v13, following the same database migration workflow as before.',
'Start with npm run db:check to ensure no conflicting pending migrations.',
'Then npm run db:migrate to apply the v13 migration files.',
'Then npm run db:validate to confirm the schema is consistent.',
'If validation fails, run npm run db:rollback immediately — do not attempt manual fixes.',
'We learned the hard way that skipping db:check causes index corruption.',
'The check-migrate-validate order is mandatory for every schema change.',
'This is the same recurring workflow we used for v12 and earlier migrations.',
'The v13 migration passed with the same three-step sequence.',
'Confirmed: the standard db migration workflow succeeded again for v13.',
],
},
]);
},
assert: async (config) => {
const { state, skillsDir } = await runExtractionAndReadState(config);
const skillBodies = await readSkillBodies(skillsDir);
const combinedSkills = skillBodies.join('\n\n');
expect(state.runs).toHaveLength(1);
expect(state.runs[0].sessionIds).toHaveLength(2);
expect(state.runs[0].skillsCreated.length).toBeGreaterThanOrEqual(1);
expect(skillBodies.length).toBeGreaterThanOrEqual(1);
expect(combinedSkills).toContain('npm run db:check');
expect(combinedSkills).toContain('npm run db:migrate');
expect(combinedSkills).toContain('npm run db:validate');
expect(combinedSkills).toMatch(/rollback/i);
// Verify the extraction agent activated skill-creator for design guidance.
expect(config.getSkillManager().isSkillActive('skill-creator')).toBe(
true,
);
},
});
});
+130
View File
@@ -0,0 +1,130 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import {
TRACKER_CREATE_TASK_TOOL_NAME,
TRACKER_UPDATE_TASK_TOOL_NAME,
} from '@google/gemini-cli-core';
import { evalTest, TEST_AGENTS } from './test-helper.js';
describe('subtask delegation eval test cases', () => {
/**
* Checks that the main agent can correctly decompose a complex, sequential
* task into subtasks using the task tracker and delegate each to the appropriate expert subagent.
*
* The task requires:
* 1. Reading requirements (researcher)
* 2. Implementing logic (developer)
* 3. Documenting (doc expert)
*/
evalTest('USUALLY_PASSES', {
name: 'should delegate sequential subtasks to relevant experts using the task tracker',
params: {
settings: {
experimental: {
enableAgents: true,
taskTracker: true,
},
},
},
prompt:
'Please read the requirements in requirements.txt using a researcher, then implement the requested logic in src/logic.ts using a developer, and finally document the implementation in docs/logic.md using a documentation expert.',
files: {
'.gemini/agents/researcher.md': `---
name: researcher
description: Expert in reading files and extracting requirements.
tools:
- read_file
---
You are the researcher. Read the provided file and extract requirements.`,
'.gemini/agents/developer.md': `---
name: developer
description: Expert in implementing logic in TypeScript.
tools:
- write_file
---
You are the developer. Implement the requested logic in the specified file.`,
'.gemini/agents/doc-expert.md': `---
name: doc-expert
description: Expert in writing technical documentation.
tools:
- write_file
---
You are the doc expert. Document the provided implementation clearly.`,
'requirements.txt':
'Implement a function named "calculateSum" that adds two numbers.',
},
assert: async (rig, _result) => {
// Verify tracker tasks were created
const wasCreateCalled = await rig.waitForToolCall(
TRACKER_CREATE_TASK_TOOL_NAME,
);
expect(wasCreateCalled).toBe(true);
const toolLogs = rig.readToolLogs();
const createCalls = toolLogs.filter(
(l) => l.toolRequest.name === TRACKER_CREATE_TASK_TOOL_NAME,
);
expect(createCalls.length).toBeGreaterThanOrEqual(3);
await rig.expectToolCallSuccess([
'researcher',
'developer',
'doc-expert',
]);
const logicFile = rig.readFile('src/logic.ts');
const docFile = rig.readFile('docs/logic.md');
expect(logicFile).toContain('calculateSum');
expect(docFile).toBeTruthy();
},
});
/**
* Checks that the main agent can delegate a batch of independent subtasks
* to multiple subagents in parallel using the task tracker to manage state.
*/
evalTest('USUALLY_PASSES', {
name: 'should delegate independent subtasks to specialists using the task tracker',
params: {
settings: {
experimental: {
enableAgents: true,
taskTracker: true,
},
},
},
prompt:
'Please update the project for internationalization (i18n), audit the security of the current code, and update the CSS to use a blue theme. Use specialized experts for each task.',
files: {
...TEST_AGENTS.I18N_AGENT.asFile(),
...TEST_AGENTS.SECURITY_AGENT.asFile(),
...TEST_AGENTS.CSS_AGENT.asFile(),
'index.ts': 'console.log("Hello World");',
},
assert: async (rig, _result) => {
// Verify tracker tasks were created
const wasCreateCalled = await rig.waitForToolCall(
TRACKER_CREATE_TASK_TOOL_NAME,
);
expect(wasCreateCalled).toBe(true);
const toolLogs = rig.readToolLogs();
const createCalls = toolLogs.filter(
(l) => l.toolRequest.name === TRACKER_CREATE_TASK_TOOL_NAME,
);
expect(createCalls.length).toBeGreaterThanOrEqual(3);
await rig.expectToolCallSuccess([
TEST_AGENTS.I18N_AGENT.name,
TEST_AGENTS.SECURITY_AGENT.name,
TEST_AGENTS.CSS_AGENT.name,
]);
},
});
});
@@ -0,0 +1,275 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
describe('run_shell_command streaming to file regression', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should stream large outputs to a file and verify full content presence', async () => {
await rig.setup(
'should stream large outputs to a file and verify full content presence',
{
settings: { tools: { core: ['run_shell_command'] } },
},
);
const numLines = 20000;
const testFileName = 'large_output_test.txt';
const testFilePath = path.join(rig.testDir!, testFileName);
// Create a ~20MB file with unique content at start and end
const startMarker = 'START_OF_FILE_MARKER';
const endMarker = 'END_OF_FILE_MARKER';
const stream = fs.createWriteStream(testFilePath);
stream.write(startMarker + '\n');
for (let i = 0; i < numLines; i++) {
stream.write(`Line ${i + 1}: ` + 'A'.repeat(1000) + '\n');
}
stream.write(endMarker + '\n');
await new Promise((resolve) => stream.end(resolve));
const fileSize = fs.statSync(testFilePath).size;
expect(fileSize).toBeGreaterThan(20000000);
const prompt = `Use run_shell_command to cat ${testFileName} and say 'Done.'`;
await rig.run({ args: prompt });
let savedFilePath = '';
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
if (fs.existsSync(tmpdir)) {
const findFiles = (dir: string): string[] => {
let results: string[] = [];
const list = fs.readdirSync(dir, { withFileTypes: true });
for (const file of list) {
const fullPath = path.join(dir, file.name);
if (file.isDirectory()) {
results = results.concat(findFiles(fullPath));
} else if (file.isFile() && file.name.endsWith('.txt')) {
results.push(fullPath);
} else if (file.isFile() && file.name.endsWith('.log')) {
results.push(fullPath);
}
}
return results;
};
const files = findFiles(tmpdir);
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
for (const p of files) {
try {
const stat = fs.statSync(p);
if (stat.size >= 20000000) {
savedFilePath = p;
break;
}
} catch {
// ignore
}
}
if (!savedFilePath) {
const fileStats = files.map((p) => {
try {
return { p, size: fs.statSync(p).size };
} catch {
return { p, size: 'error' };
}
});
rig.log('Available files:', JSON.stringify(fileStats, null, 2));
}
}
expect(
savedFilePath,
`Expected to find a saved output file >= 20MB in ${tmpdir}`,
).toBeTruthy();
const savedContent = fs.readFileSync(savedFilePath, 'utf8');
expect(savedContent).toContain(startMarker);
expect(savedContent).toContain(endMarker);
expect(savedContent.length).toBeGreaterThanOrEqual(fileSize);
fs.unlinkSync(savedFilePath);
}, 120000);
it('should stream very large (50MB) outputs to a file and verify full content presence', async () => {
await rig.setup(
'should stream very large (50MB) outputs to a file and verify full content presence',
{
settings: { tools: { core: ['run_shell_command'] } },
},
);
const numLines = 1000000;
const testFileName = 'very_large_output_test.txt';
const testFilePath = path.join(rig.testDir!, testFileName);
// Create a ~50MB file with unique content at start and end
const startMarker = 'START_OF_FILE_MARKER';
const endMarker = 'END_OF_FILE_MARKER';
const stream = fs.createWriteStream(testFilePath);
stream.write(startMarker + '\n');
for (let i = 0; i < numLines; i++) {
stream.write(`Line ${i + 1}: ` + 'A'.repeat(40) + '\n');
}
stream.write(endMarker + '\n');
await new Promise((resolve) => stream.end(resolve));
const fileSize = fs.statSync(testFilePath).size;
expect(fileSize).toBeGreaterThan(45000000);
const prompt = `Use run_shell_command to cat ${testFileName} and say 'Done.'`;
await rig.run({ args: prompt });
let savedFilePath = '';
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
if (fs.existsSync(tmpdir)) {
const findFiles = (dir: string): string[] => {
let results: string[] = [];
const list = fs.readdirSync(dir, { withFileTypes: true });
for (const file of list) {
const fullPath = path.join(dir, file.name);
if (file.isDirectory()) {
results = results.concat(findFiles(fullPath));
} else if (file.isFile() && file.name.endsWith('.txt')) {
results.push(fullPath);
} else if (file.isFile() && file.name.endsWith('.log')) {
results.push(fullPath);
}
}
return results;
};
const files = findFiles(tmpdir);
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
for (const p of files) {
try {
const stat = fs.statSync(p);
if (stat.size >= 20000000) {
savedFilePath = p;
break;
}
} catch {
// ignore
}
}
if (!savedFilePath) {
const fileStats = files.map((p) => {
try {
return { p, size: fs.statSync(p).size };
} catch {
return { p, size: 'error' };
}
});
rig.log('Available files:', JSON.stringify(fileStats, null, 2));
}
}
expect(
savedFilePath,
`Expected to find a saved output file >= 20MB in ${tmpdir}`,
).toBeTruthy();
const savedContent = fs.readFileSync(savedFilePath, 'utf8');
expect(savedContent).toContain(startMarker);
expect(savedContent).toContain(endMarker);
expect(savedContent.length).toBeGreaterThanOrEqual(fileSize);
fs.unlinkSync(savedFilePath);
}, 120000);
it('should produce clean output resolving carriage returns and backspaces', async () => {
await rig.setup(
'should produce clean output resolving carriage returns and backspaces',
{
settings: {
tools: { core: ['run_shell_command'] },
},
},
);
const script = `
import sys
import time
# Fill buffer to force file streaming/truncation
# 45000 chars to be safe (default threshold is 40000)
print('A' * 45000)
sys.stdout.flush()
# Test sequence
print('XXXXX', end='', flush=True)
time.sleep(0.5)
print('\\rYYYYY', end='', flush=True)
time.sleep(0.5)
print('\\nNext Line', end='', flush=True)
`;
const scriptPath = path.join(rig.testDir!, 'test_script.py');
fs.writeFileSync(scriptPath, script);
const prompt = `run_shell_command python3 "${scriptPath}"`;
await rig.run({ args: prompt });
let savedFilePath = '';
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
if (fs.existsSync(tmpdir)) {
const findFiles = (dir: string): string[] => {
let results: string[] = [];
const list = fs.readdirSync(dir, { withFileTypes: true });
for (const file of list) {
const fullPath = path.join(dir, file.name);
if (file.isDirectory()) {
results = results.concat(findFiles(fullPath));
} else if (file.isFile() && file.name.endsWith('.txt')) {
results.push(fullPath);
}
}
return results;
};
const files = findFiles(tmpdir);
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
if (files.length > 0) {
savedFilePath = files[0];
}
}
expect(savedFilePath, 'Output file should exist').toBeTruthy();
const content = fs.readFileSync(savedFilePath, 'utf8');
// Verify it contains the large chunk
expect(content).toContain('AAAA');
// Verify cleanup logic:
// 1. The final text "YYYYY" should be present.
expect(content).toContain('YYYYY');
// 2. The next line should be present.
expect(content).toContain('Next Line');
// 3. Verify overwrite happened.
// In raw output, we would have "XXXXX...YYYYY".
// In processed output, "YYYYY" overwrites "XXXXX".
// We confirm that escape codes are stripped (processed text).
// 4. Check for ANSI escape codes (like \\x1b) just in case
expect(content).not.toContain('\x1b');
}, 60000);
});
+23 -20
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import React from 'react';
import { Box, Text } from 'ink';
import type { AnsiLine, AnsiOutput, AnsiToken } from '@google/gemini-cli-core';
@@ -53,23 +53,26 @@ export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
);
};
export const AnsiLineText: React.FC<{ line: AnsiLine }> = ({ line }) => (
<Text>
{line.length > 0
? line.map((token: AnsiToken, tokenIndex: number) => (
<Text
key={tokenIndex}
color={token.fg}
backgroundColor={token.bg}
inverse={token.inverse}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
export const AnsiLineText = React.memo<{ line: AnsiLine }>(
({ line }: { line: AnsiLine }) => (
<Text>
{line.length > 0
? line.map((token: AnsiToken, tokenIndex: number) => (
<Text
key={tokenIndex}
color={token.fg}
backgroundColor={token.bg}
inverse={token.inverse}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
),
);
AnsiLineText.displayName = 'AnsiLineText';
@@ -3877,8 +3877,9 @@ describe('InputPrompt', () => {
// 1. Verify initial placeholder
await waitFor(() => {
expect(stdout.lastFrame()).toMatchSnapshot();
expect(stdout.lastFrame()).toContain('[Pasted Text: 10 lines]');
});
expect(stdout.lastFrame()).toMatchSnapshot();
// Simulate double-click to expand
await simulateClick(5, 2);
@@ -3886,8 +3887,9 @@ describe('InputPrompt', () => {
// 2. Verify expanded content is visible
await waitFor(() => {
expect(stdout.lastFrame()).toMatchSnapshot();
expect(stdout.lastFrame()).toContain('line10');
});
expect(stdout.lastFrame()).toMatchSnapshot();
// Simulate double-click to collapse
await simulateClick(5, 2);
@@ -3895,8 +3897,9 @@ describe('InputPrompt', () => {
// 3. Verify placeholder is restored
await waitFor(() => {
expect(stdout.lastFrame()).toMatchSnapshot();
expect(stdout.lastFrame()).toContain('[Pasted Text: 10 lines]');
});
expect(stdout.lastFrame()).toMatchSnapshot();
unmount();
});
@@ -156,7 +156,16 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 2`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Pasted Text: 10 lines]
> line1
line2
line3
line4
line5
line6
line7
line8
line9
line10
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
@@ -11,12 +11,12 @@ exports[`Initial Theme Selection > should default to a dark theme when terminal
│ ● 4. Default Dark (Matches terminal) │ 3 a, b = 0, 1 │ │
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
│ 8. Shades Of Purple Dark │ │ │
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. ANSI Light │ │ │
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
│ 8. Holiday Dark │ │ │
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -34,14 +34,14 @@ exports[`Initial Theme Selection > should default to a light theme when terminal
│ 2. Ayu Light │ 1 # function │ │
│ ● 3. Default Light │ 2 def fibonacci(n): │ │
│ 4. GitHub Light │ 3 a, b = 0, 1 │ │
│ 5. Google Code Light │ 4 for _ in range(n): │ │
│ 6. Solarized Light │ 5 a, b = b, a + b │ │
│ 7. Xcode Light │ 6 return a │ │
│ 8. ANSI Dark (Incompatible) │ │ │
│ 9. Atom One Dark (Incompatible) │ 1 - print("Hello, " + name) │ │
│ 10. Ayu Dark (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
│ 11. Default Dark (Incompatible) │ │ │
│ 12. Dracula Dark (Incompatible) └─────────────────────────────────────────────────┘ │
│ 5. GitHub Light Colorblind Light (Mat… │ 4 for _ in range(n): │ │
│ 6. Google Code Light │ 5 a, b = b, a + b │ │
│ 7. Solarized Light │ 6 return a │ │
│ 8. Xcode Light │ │ │
│ 9. ANSI Dark (Incompatible) │ 1 - print("Hello, " + name) │ │
│ 10. Atom One Dark (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
│ 11. Ayu Dark (Incompatible) │ │ │
│ 12. Default Dark (Incompatible) └─────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -61,12 +61,12 @@ exports[`Initial Theme Selection > should use the theme from settings even if te
│ 4. Default Dark (Matches terminal) │ 3 a, b = 0, 1 │ │
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
│ 8. Shades Of Purple Dark │ │ │
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. ANSI Light │ │ │
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
│ 8. Holiday Dark │ │ │
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -100,12 +100,12 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
│ 4. Default Dark │ 3 a, b = 0, 1 │ │
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
│ 8. Shades Of Purple Dark │ │ │
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. ANSI Light │ │ │
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
│ 8. Holiday Dark │ │ │
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -125,12 +125,12 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
│ 4. Default Dark │ 3 a, b = 0, 1 │ │
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
│ 8. Shades Of Purple Dark │ │ │
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. ANSI Light │ │ │
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
│ 8. Holiday Dark │ │ │
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ ▼ │
│ ╭─────────────────────────────────────────────────╮ │
│ │ DEVELOPER TOOLS (Not visible to users) │ │
@@ -858,11 +858,81 @@ describe('useSlashCommandProcessor', () => {
});
describe('Lifecycle', () => {
it('removes the IDE status listener on unmount after async initialization', async () => {
let resolveIdeClient:
| ((client: {
addStatusChangeListener: (listener: () => void) => void;
removeStatusChangeListener: (listener: () => void) => void;
}) => void)
| undefined;
const addStatusChangeListener = vi.fn();
const removeStatusChangeListener = vi.fn();
mockIdeClientGetInstance.mockImplementation(
() =>
new Promise((resolve) => {
resolveIdeClient = resolve;
}),
);
const result = await setupProcessorHook();
await act(async () => {
resolveIdeClient?.({
addStatusChangeListener,
removeStatusChangeListener,
});
});
result.unmount();
unmountHook = undefined;
expect(addStatusChangeListener).toHaveBeenCalledTimes(1);
expect(removeStatusChangeListener).toHaveBeenCalledTimes(1);
expect(removeStatusChangeListener).toHaveBeenCalledWith(
addStatusChangeListener.mock.calls[0]?.[0],
);
});
it('does not register an IDE status listener if unmounted before async initialization resolves', async () => {
let resolveIdeClient:
| ((client: {
addStatusChangeListener: (listener: () => void) => void;
removeStatusChangeListener: (listener: () => void) => void;
}) => void)
| undefined;
const addStatusChangeListener = vi.fn();
const removeStatusChangeListener = vi.fn();
mockIdeClientGetInstance.mockImplementation(
() =>
new Promise((resolve) => {
resolveIdeClient = resolve;
}),
);
const result = await setupProcessorHook();
result.unmount();
unmountHook = undefined;
await act(async () => {
resolveIdeClient?.({
addStatusChangeListener,
removeStatusChangeListener,
});
});
expect(addStatusChangeListener).not.toHaveBeenCalled();
expect(removeStatusChangeListener).not.toHaveBeenCalled();
});
it('should abort command loading when the hook unmounts', async () => {
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
const { unmount } = await setupProcessorHook();
unmount();
unmountHook = undefined;
expect(abortSpy).toHaveBeenCalledTimes(1);
});
@@ -281,10 +281,16 @@ export const useSlashCommandProcessor = (
const listener = () => {
reloadCommands();
};
let isActive = true;
let activeIdeClient: IdeClient | undefined;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
const ideClient = await IdeClient.getInstance();
if (!isActive) {
return;
}
activeIdeClient = ideClient;
ideClient.addStatusChangeListener(listener);
})();
@@ -307,11 +313,8 @@ export const useSlashCommandProcessor = (
coreEvents.on('extensionsStopping', extensionEventListener);
return () => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
const ideClient = await IdeClient.getInstance();
ideClient.removeStatusChangeListener(listener);
})();
isActive = false;
activeIdeClient?.removeStatusChangeListener(listener);
removeMCPStatusChangeListener(listener);
coreEvents.off('extensionsStarting', extensionEventListener);
coreEvents.off('extensionsStopping', extensionEventListener);
@@ -76,7 +76,23 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
isBinary: mockIsBinary,
};
});
vi.mock('node:fs');
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const mocked = {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
createWriteStream: vi.fn(),
promises: {
...actual.promises,
unlink: vi.fn().mockResolvedValue(undefined),
},
};
return {
...mocked,
default: mocked,
};
});
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
const mocked = {
@@ -144,6 +160,10 @@ describe('useExecutionLifecycle', () => {
enableEnvironmentVariableRedaction: false,
},
}),
getTruncateToolOutputThreshold: () => 40000,
storage: {
getProjectTempDir: () => '/tmp/project',
},
} as unknown as Config;
mockGeminiClient = { addHistory: vi.fn() } as unknown as GeminiClient;
@@ -155,6 +175,16 @@ describe('useExecutionLifecycle', () => {
mockIsBinary.mockReturnValue(false);
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(fs.createWriteStream).mockReturnValue({
write: vi.fn(),
end: vi.fn().mockImplementation((cb: () => void) => {
if (cb) cb();
}),
destroy: vi.fn(),
bytesWritten: 0,
closed: false,
} as unknown as fs.WriteStream);
mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => {
mockShellOutputCallback = callback;
return Promise.resolve({
@@ -646,7 +676,7 @@ describe('useExecutionLifecycle', () => {
});
const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp');
// Verify that the temporary file was cleaned up
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
expect(vi.mocked(fs.promises.unlink)).toHaveBeenCalledWith(tmpFile);
expect(setShellInputFocusedMock).toHaveBeenCalledWith(false);
});
@@ -674,7 +704,7 @@ describe('useExecutionLifecycle', () => {
expect(finalHistoryItem.tools[0].resultDisplay).toContain(
"WARNING: shell mode is stateless; the directory change to '/test/dir/new' will not persist.",
);
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
expect(vi.mocked(fs.promises.unlink)).toHaveBeenCalledWith(tmpFile);
});
it('should NOT show a warning if the directory does not change', async () => {
@@ -20,6 +20,8 @@ import {
ShellExecutionService,
ExecutionLifecycleService,
CoreToolCallStatus,
moveToolOutputToFile,
debugLogger,
} from '@google/gemini-cli-core';
import { type PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -39,16 +41,15 @@ export { type BackgroundTask };
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
const RESTORE_VISIBILITY_DELAY_MS = 300;
const MAX_OUTPUT_LENGTH = 10000;
function addShellCommandToGeminiHistory(
geminiClient: GeminiClient,
rawQuery: string,
resultText: string,
maxOutputLength: number,
) {
const modelContent =
resultText.length > MAX_OUTPUT_LENGTH
? resultText.substring(0, MAX_OUTPUT_LENGTH) + '\n... (truncated)'
maxOutputLength > 0 && resultText.length > maxOutputLength
? resultText.substring(0, maxOutputLength) + '\n... (truncated)'
: resultText;
// Escape backticks to prevent prompt injection breakouts
@@ -424,6 +425,9 @@ export const useExecutionLifecycle = (
let shouldUpdate = false;
switch (event.type) {
case 'raw_data':
case 'file_data':
break;
case 'data':
if (isBinaryStream) break;
if (typeof event.chunk === 'string') {
@@ -533,6 +537,24 @@ export const useExecutionLifecycle = (
} else {
mainContent =
result.output.trim() || '(Command produced no output)';
if (result.fullOutputFilePath) {
const { outputFile: savedPath } = await moveToolOutputToFile(
result.fullOutputFilePath,
SHELL_COMMAND_NAME,
callId,
config.storage.getProjectTempDir(),
config.getSessionId(),
);
const warning = `[Full command output saved to: ${savedPath}]`;
mainContent = mainContent.includes(
'[GEMINI_CLI_WARNING: Output truncated.',
)
? mainContent.replace(
/\[GEMINI_CLI_WARNING: Output truncated\..*?\]/,
warning,
)
: `${mainContent}\n\n${warning}`;
}
}
let finalOutput: string | AnsiOutput =
@@ -617,7 +639,12 @@ export const useExecutionLifecycle = (
);
}
addShellCommandToGeminiHistory(geminiClient, rawQuery, mainContent);
addShellCommandToGeminiHistory(
geminiClient,
rawQuery,
mainContent,
config.getTruncateToolOutputThreshold(),
);
} catch (err) {
setPendingHistoryItem(null);
const errorMessage = err instanceof Error ? err.message : String(err);
@@ -630,8 +657,13 @@ export const useExecutionLifecycle = (
);
} finally {
abortSignal.removeEventListener('abort', abortHandler);
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
fs.unlinkSync(pwdFilePath);
if (pwdFilePath) {
fs.promises.unlink(pwdFilePath).catch((err) => {
debugLogger.warn(
`Failed to cleanup pwd file: ${pwdFilePath}`,
err,
);
});
}
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
@@ -0,0 +1,147 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from '../../theme.js';
import { interpolateColor } from '../../color-utils.js';
const githubDarkColorblindColors: ColorsTheme = {
type: 'dark',
Background: '#0d1117',
Foreground: '#e6edf3',
LightBlue: '#a5d6ff',
AccentBlue: '#79c0ff',
AccentPurple: '#d2a8ff',
AccentCyan: '#a5d6ff',
AccentGreen: '#a5d6ff',
AccentYellow: '#d29922',
AccentRed: '#f0883e',
DiffAdded: '#0d161f',
DiffRemoved: '#1d150e',
Comment: '#7d8590',
Gray: '#7d8590',
DarkGray: interpolateColor('#7d8590', '#0d1117', 0.5),
GradientColors: ['#58a6ff', '#f0883e'],
};
export const GitHubDarkColorblind: Theme = new Theme(
'GitHub Dark Colorblind',
'dark',
{
hljs: {
display: 'block',
overflowX: 'auto',
padding: '0.5em',
color: githubDarkColorblindColors.Foreground,
background: githubDarkColorblindColors.Background,
},
'hljs-comment': {
color: githubDarkColorblindColors.Comment,
fontStyle: 'italic',
},
'hljs-quote': {
color: githubDarkColorblindColors.Comment,
fontStyle: 'italic',
},
'hljs-keyword': {
color: githubDarkColorblindColors.AccentRed,
fontWeight: 'bold',
},
'hljs-selector-tag': {
color: githubDarkColorblindColors.AccentRed,
fontWeight: 'bold',
},
'hljs-subst': {
color: githubDarkColorblindColors.Foreground,
},
'hljs-number': {
color: githubDarkColorblindColors.LightBlue,
},
'hljs-literal': {
color: githubDarkColorblindColors.LightBlue,
},
'hljs-variable': {
color: githubDarkColorblindColors.Foreground,
},
'hljs-template-variable': {
color: githubDarkColorblindColors.Foreground,
},
'hljs-tag .hljs-attr': {
color: githubDarkColorblindColors.AccentYellow,
},
'hljs-string': {
color: githubDarkColorblindColors.AccentCyan,
},
'hljs-doctag': {
color: githubDarkColorblindColors.AccentCyan,
},
'hljs-title': {
color: githubDarkColorblindColors.AccentPurple,
fontWeight: 'bold',
},
'hljs-section': {
color: githubDarkColorblindColors.AccentPurple,
fontWeight: 'bold',
},
'hljs-selector-id': {
color: githubDarkColorblindColors.AccentPurple,
fontWeight: 'bold',
},
'hljs-type': {
color: githubDarkColorblindColors.AccentGreen,
fontWeight: 'bold',
},
'hljs-class .hljs-title': {
color: githubDarkColorblindColors.AccentGreen,
fontWeight: 'bold',
},
'hljs-tag': {
color: githubDarkColorblindColors.AccentGreen,
},
'hljs-name': {
color: githubDarkColorblindColors.AccentGreen,
},
'hljs-attribute': {
color: githubDarkColorblindColors.LightBlue,
},
'hljs-regexp': {
color: githubDarkColorblindColors.AccentCyan,
},
'hljs-link': {
color: githubDarkColorblindColors.AccentCyan,
},
'hljs-symbol': {
color: githubDarkColorblindColors.AccentPurple,
},
'hljs-bullet': {
color: githubDarkColorblindColors.AccentPurple,
},
'hljs-built_in': {
color: githubDarkColorblindColors.LightBlue,
},
'hljs-builtin-name': {
color: githubDarkColorblindColors.LightBlue,
},
'hljs-meta': {
color: githubDarkColorblindColors.LightBlue,
fontWeight: 'bold',
},
'hljs-deletion': {
background: '#682d0f',
color: githubDarkColorblindColors.AccentRed,
},
'hljs-addition': {
background: '#0c2d6b',
color: githubDarkColorblindColors.AccentGreen,
},
'hljs-emphasis': {
fontStyle: 'italic',
},
'hljs-strong': {
fontWeight: 'bold',
},
},
githubDarkColorblindColors,
);
@@ -0,0 +1,147 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from '../../theme.js';
import { interpolateColor } from '../../color-utils.js';
const githubLightColorblindColors: ColorsTheme = {
type: 'light',
Background: '#ffffff',
Foreground: '#1f2328',
LightBlue: '#0a3069',
AccentBlue: '#0550ae',
AccentPurple: '#8250df',
AccentCyan: '#0a3069',
AccentGreen: '#0969da',
AccentYellow: '#9a6700',
AccentRed: '#bc4c00',
DiffAdded: '#ddf4ff',
DiffRemoved: '#fff1e5',
Comment: '#656d76',
Gray: '#656d76',
DarkGray: interpolateColor('#656d76', '#ffffff', 0.5),
GradientColors: ['#0969da', '#bc4c00'],
};
export const GitHubLightColorblind: Theme = new Theme(
'GitHub Light Colorblind',
'light',
{
hljs: {
display: 'block',
overflowX: 'auto',
padding: '0.5em',
color: githubLightColorblindColors.Foreground,
background: githubLightColorblindColors.Background,
},
'hljs-comment': {
color: githubLightColorblindColors.Comment,
fontStyle: 'italic',
},
'hljs-quote': {
color: githubLightColorblindColors.Comment,
fontStyle: 'italic',
},
'hljs-keyword': {
color: githubLightColorblindColors.AccentRed,
fontWeight: 'bold',
},
'hljs-selector-tag': {
color: githubLightColorblindColors.AccentRed,
fontWeight: 'bold',
},
'hljs-subst': {
color: githubLightColorblindColors.Foreground,
},
'hljs-number': {
color: githubLightColorblindColors.LightBlue,
},
'hljs-literal': {
color: githubLightColorblindColors.LightBlue,
},
'hljs-variable': {
color: githubLightColorblindColors.Foreground,
},
'hljs-template-variable': {
color: githubLightColorblindColors.Foreground,
},
'hljs-tag .hljs-attr': {
color: githubLightColorblindColors.AccentYellow,
},
'hljs-string': {
color: githubLightColorblindColors.AccentCyan,
},
'hljs-doctag': {
color: githubLightColorblindColors.AccentCyan,
},
'hljs-title': {
color: githubLightColorblindColors.AccentPurple,
fontWeight: 'bold',
},
'hljs-section': {
color: githubLightColorblindColors.AccentPurple,
fontWeight: 'bold',
},
'hljs-selector-id': {
color: githubLightColorblindColors.AccentPurple,
fontWeight: 'bold',
},
'hljs-type': {
color: githubLightColorblindColors.AccentGreen,
fontWeight: 'bold',
},
'hljs-class .hljs-title': {
color: githubLightColorblindColors.AccentGreen,
fontWeight: 'bold',
},
'hljs-tag': {
color: githubLightColorblindColors.AccentGreen,
},
'hljs-name': {
color: githubLightColorblindColors.AccentGreen,
},
'hljs-attribute': {
color: githubLightColorblindColors.LightBlue,
},
'hljs-regexp': {
color: githubLightColorblindColors.AccentCyan,
},
'hljs-link': {
color: githubLightColorblindColors.AccentCyan,
},
'hljs-symbol': {
color: githubLightColorblindColors.AccentPurple,
},
'hljs-bullet': {
color: githubLightColorblindColors.AccentPurple,
},
'hljs-built_in': {
color: githubLightColorblindColors.LightBlue,
},
'hljs-builtin-name': {
color: githubLightColorblindColors.LightBlue,
},
'hljs-meta': {
color: githubLightColorblindColors.LightBlue,
fontWeight: 'bold',
},
'hljs-deletion': {
background: '#fff1e5',
color: githubLightColorblindColors.AccentRed,
},
'hljs-addition': {
background: '#ddf4ff',
color: githubLightColorblindColors.AccentGreen,
},
'hljs-emphasis': {
fontStyle: 'italic',
},
'hljs-strong': {
fontWeight: 'bold',
},
},
githubLightColorblindColors,
);
@@ -10,6 +10,8 @@ import { AtomOneDark } from './builtin/dark/atom-one-dark.js';
import { Dracula } from './builtin/dark/dracula-dark.js';
import { GitHubDark } from './builtin/dark/github-dark.js';
import { GitHubLight } from './builtin/light/github-light.js';
import { GitHubDarkColorblind } from './builtin/dark/github-dark-colorblind.js';
import { GitHubLightColorblind } from './builtin/light/github-light-colorblind.js';
import { GoogleCode } from './builtin/light/googlecode-light.js';
import { Holiday } from './builtin/dark/holiday-dark.js';
import { DefaultLight } from './builtin/light/default-light.js';
@@ -79,6 +81,8 @@ class ThemeManager {
DefaultDark,
GitHubDark,
GitHubLight,
GitHubDarkColorblind,
GitHubLightColorblind,
GoogleCode,
Holiday,
ShadesOfPurple,
@@ -0,0 +1,90 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { SkillExtractionAgent } from './skill-extraction-agent.js';
import {
EDIT_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
} from '../tools/tool-names.js';
import { PREVIEW_GEMINI_FLASH_MODEL } from '../config/models.js';
describe('SkillExtractionAgent', () => {
const skillsDir = '/tmp/skills';
const sessionIndex =
'[NEW] Debug login flow (12 user msgs) — /tmp/chats/session-1.json';
const existingSkillsSummary =
'## Workspace Skills (.gemini/skills — do NOT duplicate)\n- **existing-skill**: Existing description';
const agent = SkillExtractionAgent(
skillsDir,
sessionIndex,
existingSkillsSummary,
);
it('should expose expected metadata, model, and tools', () => {
expect(agent.kind).toBe('local');
expect(agent.name).toBe('confucius');
expect(agent.displayName).toBe('Skill Extractor');
expect(agent.modelConfig.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
expect(agent.toolConfig?.tools).toEqual(
expect.arrayContaining([
READ_FILE_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
LS_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
]),
);
});
it('should default to no skill unless recurrence and durability are proven', () => {
const prompt = agent.promptConfig.systemPrompt;
expect(prompt).toContain('Default to NO SKILL.');
expect(prompt).toContain(
'strong evidence this will recur for future agents in this repo/workflow',
);
expect(prompt).toContain('broader than a single incident');
expect(prompt).toContain('A skill MUST meet ALL of these criteria:');
expect(prompt).toContain(
'Future agents in this repo/workflow are likely to need it',
);
});
it('should explicitly reject one-off incidents and single-session preferences', () => {
const prompt = agent.promptConfig.systemPrompt;
expect(prompt).toContain('Single-session preferences');
expect(prompt).toContain('One-off incidents');
expect(prompt).toContain('Output-style preferences');
expect(prompt).toContain('cannot survive renaming the specific');
});
it('should warn that session summaries are user-intent summaries, not workflow evidence', () => {
const query = agent.promptConfig.query ?? '';
expect(query).toContain(existingSkillsSummary);
expect(query).toContain(sessionIndex);
expect(query).toContain(
'The summary is a user-intent summary, not a workflow summary.',
);
expect(query).toContain(
'The session summaries describe user intent, not workflow details.',
);
expect(query).toContain(
'Only write a skill if the evidence shows a durable, recurring workflow',
);
expect(query).toContain(
'If recurrence or future reuse is unclear, create no skill and explain why.',
);
});
});
@@ -7,11 +7,13 @@
import { z } from 'zod';
import type { LocalAgentDefinition } from './types.js';
import {
ACTIVATE_SKILL_TOOL_NAME,
EDIT_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
} from '../tools/tool-names.js';
import { PREVIEW_GEMINI_FLASH_MODEL } from '../config/models.js';
@@ -36,7 +38,7 @@ function buildSystemPrompt(skillsDir: string): string {
'- solve similar tasks with fewer tool calls and fewer reasoning tokens',
'- reuse proven workflows and verification checklists',
'- avoid known failure modes and landmines',
'- anticipate user preferences without being reminded',
'- capture durable workflow constraints that future agents are likely to encounter again',
'',
'============================================================',
'SAFETY AND HYGIENE (STRICT)',
@@ -59,6 +61,10 @@ function buildSystemPrompt(skillsDir: string): string {
'1. "Is this something a competent agent would NOT already know?" If no, STOP.',
'2. "Does an existing skill (listed below) already cover this?" If yes, STOP.',
'3. "Can I write a concrete, step-by-step procedure?" If no, STOP.',
'4. "Is there strong evidence this will recur for future agents in this repo/workflow?" If no, STOP.',
'5. "Is this broader than a single incident (one bug, one ticket, one branch, one date, one exact error)?" If no, STOP.',
'',
'Default to NO SKILL.',
'',
'Do NOT create skills for:',
'',
@@ -67,6 +73,10 @@ function buildSystemPrompt(skillsDir: string): string {
'- **Pure Q&A**: The user asked "how does X work?" and got an answer. No procedure.',
'- **Brainstorming/design**: Discussion of how to build something, without a validated',
' implementation that produced a reusable procedure.',
'- **Single-session preferences**: User-specific style/output preferences or workflow',
' preferences mentioned only once.',
'- **One-off incidents**: Debugging or incident response tied to a single bug, ticket,',
' branch, date, or exact error string.',
'- **Anything already covered by an existing skill** (global, workspace, builtin, or',
' previously extracted). Check the "Existing Skills" section carefully.',
'',
@@ -74,31 +84,40 @@ function buildSystemPrompt(skillsDir: string): string {
'WHAT COUNTS AS A SKILL',
'============================================================',
'',
'A skill MUST meet BOTH of these criteria:',
'A skill MUST meet ALL of these criteria:',
'',
'1. **Procedural and concrete**: It can be expressed as numbered steps with specific',
' commands, paths, or code patterns. If you can only write vague guidance, it is NOT',
' a skill. "Be careful with X" is advice, not a skill.',
'',
'2. **Non-obvious and project-specific**: A competent agent would NOT already know this.',
' It encodes project-specific knowledge, non-obvious ordering constraints, or',
' hard-won failure shields that cannot be inferred from the codebase alone.',
'2. **Durable and reusable**: Future agents in this repo/workflow are likely to need it',
' again. If it only solved one incident, it is NOT a skill.',
'',
'Confidence tiers (prefer higher tiers):',
'3. **Evidence-backed and project-specific**: It encodes project-specific knowledge,',
' repeated operational constraints, or hard-won failure shields supported by session',
' evidence. Do not assume something is non-obvious just because it sounds detailed.',
'',
'**High confidence** — create the skill:',
'- The same workflow appeared in multiple sessions (cross-session repetition)',
'- A multi-step procedure was validated (tests passed, user confirmed success)',
'Confidence tiers:',
'',
'**Medium confidence** — create the skill if it is clearly project-specific:',
'- A project-specific build/test/deploy/release procedure was established',
'- A non-obvious ordering constraint or prerequisite was discovered',
'- A failure mode was hit and a concrete fix was found and verified',
'**High confidence** — create the skill only when recurrence/durability is clear:',
'- The same workflow appeared in multiple sessions (cross-session repetition), OR it is',
' a stable recurring repo workflow (for example setup/build/test/deploy/release) with a',
' clear future trigger',
'- The workflow was validated (tests passed, user confirmed success, or the same fix',
' worked repeatedly)',
'- The skill can be named without referencing a specific incident, bug, branch, or date',
'',
'**Medium confidence** — usually do NOT create the skill yet:',
'- A project-specific procedure appeared once and seems useful, but recurrence is not yet',
' clear',
'- A verified fix exists, but it is still tied to one incident',
'- A user correction changed the approach once, but durability is uncertain',
'',
'**Low confidence** — do NOT create the skill:',
'- A one-off debugging session with no reusable procedure',
'- Generic workflows any agent could figure out from the codebase',
'- A code review or investigation with no durable takeaway',
'- Output-style preferences that do not materially change procedure',
'',
'Aim for 0-2 skills per run. Quality over quantity.',
'',
@@ -117,8 +136,10 @@ function buildSystemPrompt(skillsDir: string): string {
'',
'What to look for:',
'',
'- User corrections: "No, do it this way" -> preference signal',
'- User corrections that change procedure in a durable way, especially when repeated',
' across sessions',
'- Repeated patterns across sessions: same commands, same file paths, same workflow',
'- Stable recurring repo lifecycle workflows with clear future triggers',
'- Failed attempts followed by successful ones -> failure shield',
'- Multi-step procedures that were validated (tests passed, user confirmed)',
'- User interruptions: "Stop, you need to X first" -> ordering constraint',
@@ -129,45 +150,8 @@ function buildSystemPrompt(skillsDir: string): string {
'- Tool outputs that are just data (file contents, search results)',
'- Speculative plans that were never executed',
"- Temporary context (current branch name, today's date, specific error IDs)",
'',
'============================================================',
'SKILL FORMAT',
'============================================================',
'',
'Each skill is a directory containing a SKILL.md file with YAML frontmatter',
'and optional supporting scripts.',
'',
'Directory structure:',
` ${skillsDir}/<skill-name>/`,
' SKILL.md # Required entrypoint',
' scripts/<tool>.* # Optional helper scripts (Python stdlib-only or shell)',
'',
'SKILL.md structure:',
'',
' ---',
' name: <skill-name>',
' description: <1-2 lines; include concrete triggers in user-like language>',
' ---',
'',
' ## When to Use',
' <Clear trigger conditions and non-goals>',
'',
' ## Procedure',
' <Numbered steps with specific commands, paths, code patterns>',
'',
' ## Pitfalls and Fixes',
' <symptom -> likely cause -> fix; only include observed failures>',
'',
' ## Verification',
' <Concrete success checks>',
'',
'Supporting scripts (optional but recommended when applicable):',
'- Put helper scripts in scripts/ and reference them from SKILL.md',
'- Prefer Python (stdlib only) or small shell scripts',
'- Make scripts safe: no destructive actions, no secrets, deterministic output',
'- Include a usage example in SKILL.md',
'',
'Naming: kebab-case (e.g., fix-lint-errors, run-migrations).',
'- Similar session summaries without matching workflow evidence',
'- One-off artifact names: bug IDs, branch names, timestamps, exact incident strings',
'',
'============================================================',
'UPDATING EXISTING SKILLS (PATCHES)',
@@ -214,7 +198,10 @@ function buildSystemPrompt(skillsDir: string): string {
'- Keep scopes distinct. Avoid overlapping "do-everything" skills.',
'- Every skill MUST have: triggers, procedure, at least one pitfall or verification step.',
'- If you cannot write a reliable procedure (too many unknowns), do NOT create the skill.',
'- Do not create skills for generic advice that any competent agent would already know.',
'- If the candidate is tied to one incident or cannot survive renaming the specific',
' bug/ticket, do NOT create it.',
'- Do not create skills for generic advice, output-style preferences, or ephemeral',
' choices that any competent agent would already know or adapt to on the fly.',
'- Prefer fewer, higher-quality skills. 0-2 skills per run is typical. 3+ is unusual.',
'',
'============================================================',
@@ -223,18 +210,32 @@ function buildSystemPrompt(skillsDir: string): string {
'',
`1. Use list_directory on ${skillsDir} to see existing skills.`,
'2. If skills exist, read their SKILL.md files to understand what is already captured.',
'3. Scan the session index provided in the query. Look for [NEW] sessions whose summaries',
' suggest workflows that ALSO appear in other sessions (either [NEW] or [old]).',
'4. Apply the minimum signal gate. If no repeated patterns are visible, report that and finish.',
'5. For promising patterns, use read_file on the session file paths to inspect the full',
' conversation. Confirm the workflow was actually repeated and validated.',
'6. For each confirmed skill, verify it meets ALL criteria (repeatable, procedural, high-leverage).',
'7. Write new SKILL.md files or update existing ones in your directory using write_file.',
'3. Use activate_skill to load the "skill-creator" skill. Follow its design guidance',
' (conciseness, progressive disclosure, frontmatter format, bundled resources) when',
' writing SKILL.md files. You may also use its init_skill.cjs script to scaffold new',
' skill directories and package_skill.cjs to validate finished skills.',
' IMPORTANT: You are a background agent with no user interaction. Skip any interactive',
' steps in the skill-creator guide (asking clarifying questions, requesting user feedback,',
' installation prompts, iteration loops). Use only its format and quality guidance.',
'4. Scan the session index provided in the query. Look for [NEW] sessions whose summaries',
' hint at workflows that ALSO appear in other sessions (either [NEW] or [old]) or at a',
' stable recurring repo workflow. Remember: summary similarity alone is NOT enough.',
'5. Apply the minimum signal gate. If recurrence or durability is not visible, report that',
' no skill should be created and finish.',
'6. For promising patterns, use read_file on the session file paths to inspect the full',
' conversation. Confirm the workflow was actually repeated and validated. Read at least',
' two sessions unless the candidate is clearly a stable recurring repo lifecycle workflow.',
'7. For each candidate, verify it meets ALL criteria. Before writing, make sure you can',
' state: future trigger, evidence sessions, recurrence signal, validation signal, and',
' why it is not generic.',
'8. Write new SKILL.md files or update existing ones in your directory.',
' Use run_shell_command to run init_skill.cjs for scaffolding and package_skill.cjs for validation.',
' For skills that live OUTSIDE your directory, write a .patch file instead (see UPDATING EXISTING SKILLS).',
'8. Write COMPLETE files — never partially update a SKILL.md.',
'9. Write COMPLETE files — never partially update a SKILL.md.',
'',
'IMPORTANT: Do NOT read every session. Only read sessions whose summaries suggest a',
'repeated pattern worth investigating. Most runs should read 0-3 sessions and create 0 skills.',
'repeated pattern or a stable recurring repo workflow worth investigating. Most runs',
'should read 0-3 sessions and create 0 skills.',
'Do not explore the codebase. Work only with the session index, session files, and the skills directory.',
].join('\n');
}
@@ -244,8 +245,9 @@ function buildSystemPrompt(skillsDir: string): string {
* writes reusable SKILL.md files to the project memory directory.
*
* This agent is designed to run in the background on session startup.
* It has restricted tool access (file tools only, no shell or user interaction)
* and is prompted to only operate within the skills memory directory.
* It has restricted tool access (file tools, shell, and skill activation no
* user interaction) and is prompted to only operate within the skills memory
* directory.
*/
export const SkillExtractionAgent = (
skillsDir: string,
@@ -279,12 +281,14 @@ export const SkillExtractionAgent = (
},
toolConfig: {
tools: [
ACTIVATE_SKILL_TOOL_NAME,
READ_FILE_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
LS_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
SHELL_TOOL_NAME,
],
},
get promptConfig() {
@@ -301,6 +305,9 @@ export const SkillExtractionAgent = (
'Below is an index of past conversation sessions. Each line shows:',
'[NEW] or [old] status, a 1-line summary, message count, and the file path.',
'',
'The summary is a user-intent summary, not a workflow summary.',
'Matching summary text alone is never enough evidence for a reusable skill.',
'',
'[NEW] = not yet processed for skill extraction (focus on these)',
'[old] = previously processed (read only if a [NEW] session hints at a repeated pattern)',
'',
@@ -319,7 +326,7 @@ export const SkillExtractionAgent = (
return {
systemPrompt: buildSystemPrompt(skillsDir),
query: `${initialContext}\n\nAnalyze the session index above. Read sessions that suggest repeated workflows using read_file. Extract reusable skills to ${skillsDir}/.`,
query: `${initialContext}\n\nAnalyze the session index above. The session summaries describe user intent, not workflow details. Read sessions that suggest repeated workflows using read_file. Only write a skill if the evidence shows a durable, recurring workflow or a stable recurring repo procedure. If recurrence or future reuse is unclear, create no skill and explain why.`,
};
},
runConfig: {
@@ -661,4 +661,82 @@ describe('ToolOutputMaskingService', () => {
)['output'],
).toContain(MASKING_INDICATOR_TAG);
});
it('should use existing outputFile if available in the tool response', async () => {
// Setup: Create a large history to trigger masking
const largeContent = 'a'.repeat(60000);
const existingOutputFile = path.join(testTempDir, 'truly_full_output.txt');
await fs.promises.writeFile(existingOutputFile, 'truly full content');
const history: Content[] = [
{
role: 'user',
parts: [{ text: 'Old turn' }],
},
{
role: 'model',
parts: [
{
functionResponse: {
name: 'shell',
id: 'call-1',
response: {
output: largeContent,
outputFile: existingOutputFile,
},
},
},
],
},
// Protection buffer
{
role: 'user',
parts: [
{
functionResponse: {
name: 'padding',
response: { output: 'B'.repeat(60000) },
},
},
],
},
{
role: 'user',
parts: [{ text: 'Newest turn' }],
},
];
mockedEstimateTokenCountSync.mockImplementation((parts: Part[]) => {
const resp = parts[0].functionResponse?.response as Record<
string,
unknown
>;
const content = (resp?.['output'] as string) ?? JSON.stringify(resp);
if (content.includes(`<${MASKING_INDICATOR_TAG}`)) return 100;
const name = parts[0].functionResponse?.name;
if (name === 'shell') return 60000;
if (name === 'padding') return 60000;
return 10;
});
// Trigger masking
const result = await service.mask(history, mockConfig);
expect(result.maskedCount).toBe(2);
const maskedPart = result.newHistory[1].parts![0];
const maskedResponse = maskedPart.functionResponse?.response as Record<
string,
unknown
>;
const maskedOutput = maskedResponse['output'] as string;
// Verify the masked snippet points to the existing file
expect(maskedOutput).toContain(
`Full output available at: ${existingOutputFile}`,
);
// Verify the path in maskedOutput is exactly the one we provided
expect(maskedOutput).toContain(existingOutputFile);
});
});
@@ -48,6 +48,18 @@ export interface MaskingResult {
tokensSaved: number;
}
interface HasOutputFile {
outputFile: string;
}
function hasOutputFile(obj: unknown): obj is HasOutputFile {
if (typeof obj !== 'object' || obj === null || !('outputFile' in obj)) {
return false;
}
const val = (obj as Record<string, unknown>)['outputFile'];
return typeof val === 'string';
}
/**
* Service to manage context window efficiency by masking bulky tool outputs (Tool Output Masking).
*
@@ -182,25 +194,44 @@ export class ToolOutputMaskingService {
const toolName = part.functionResponse.name || 'unknown_tool';
const callId = part.functionResponse.id || Date.now().toString();
const safeToolName = sanitizeFilenamePart(toolName).toLowerCase();
const safeCallId = sanitizeFilenamePart(callId).toLowerCase();
const fileName = `${safeToolName}_${safeCallId}_${Math.random()
.toString(36)
.substring(7)}.txt`;
const filePath = path.join(toolOutputsDir, fileName);
await fsPromises.writeFile(filePath, content, 'utf-8');
const originalResponse =
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(part.functionResponse.response as Record<string, unknown>) || {};
const totalLines = content.split('\n').length;
const fileSizeMB = (
Buffer.byteLength(content, 'utf8') /
1024 /
1024
).toFixed(2);
let filePath = '';
let fileSizeMB = '0.00';
let totalLines = 0;
if (hasOutputFile(originalResponse) && originalResponse.outputFile) {
filePath = originalResponse.outputFile;
try {
const stats = await fsPromises.stat(filePath);
fileSizeMB = (stats.size / 1024 / 1024).toFixed(2);
// For truly full files, we don't count lines as it's too slow.
// We just indicate it's the full file.
totalLines = -1;
} catch {
// Fallback if file is gone
filePath = '';
}
}
if (!filePath) {
const safeToolName = sanitizeFilenamePart(toolName).toLowerCase();
const safeCallId = sanitizeFilenamePart(callId).toLowerCase();
const fileName = `${safeToolName}_${safeCallId}_${Math.random()
.toString(36)
.substring(7)}.txt`;
filePath = path.join(toolOutputsDir, fileName);
await fsPromises.writeFile(filePath, content, 'utf-8');
totalLines = content.split('\n').length;
fileSizeMB = (Buffer.byteLength(content, 'utf8') / 1024 / 1024).toFixed(
2,
);
}
let preview = '';
if (toolName === SHELL_TOOL_NAME) {
+195 -8
View File
@@ -148,7 +148,7 @@ describe('createContentGenerator', () => {
);
expect(GoogleGenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
vertexai: undefined,
vertexai: false,
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.stringMatching(
@@ -365,7 +365,7 @@ describe('createContentGenerator', () => {
expect(GoogleGenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
vertexai: undefined,
vertexai: false,
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.any(String),
@@ -409,7 +409,7 @@ describe('createContentGenerator', () => {
expect(GoogleGenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
vertexai: undefined,
vertexai: false,
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.any(String),
@@ -443,7 +443,7 @@ describe('createContentGenerator', () => {
expect(GoogleGenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
vertexai: undefined,
vertexai: false,
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.any(String),
@@ -481,7 +481,7 @@ describe('createContentGenerator', () => {
);
expect(GoogleGenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
vertexai: undefined,
vertexai: false,
httpOptions: expect.objectContaining({
headers: {
'User-Agent': expect.any(String),
@@ -517,7 +517,7 @@ describe('createContentGenerator', () => {
expect(GoogleGenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
vertexai: undefined,
vertexai: false,
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.any(String),
@@ -550,7 +550,7 @@ describe('createContentGenerator', () => {
expect(GoogleGenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
vertexai: undefined,
vertexai: false,
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.any(String),
@@ -589,7 +589,7 @@ describe('createContentGenerator', () => {
expect(GoogleGenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
vertexai: undefined,
vertexai: false,
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.any(String),
@@ -638,6 +638,193 @@ describe('createContentGenerator', () => {
apiVersion: 'v1alpha',
});
});
it('should pass baseUrl to GoogleGenAI when GOOGLE_GEMINI_BASE_URL is set', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'https://gemini.test.local');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
const config = await createContentGeneratorConfig(
mockConfig,
AuthType.USE_GEMINI,
);
await createContentGenerator(config, mockConfig);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: 'test-api-key',
vertexai: false,
httpOptions: expect.objectContaining({
baseUrl: 'https://gemini.test.local',
}),
}),
);
});
it('should pass baseUrl to GoogleGenAI when GOOGLE_VERTEX_BASE_URL is set', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
vi.stubEnv('GOOGLE_VERTEX_BASE_URL', 'https://vertex.test.local');
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'my-project');
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us-central1');
const config = await createContentGeneratorConfig(
mockConfig,
AuthType.USE_VERTEX_AI,
);
await createContentGenerator(config, mockConfig);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: undefined,
vertexai: true,
httpOptions: expect.objectContaining({
baseUrl: 'https://vertex.test.local',
}),
}),
);
});
it('should prefer GOOGLE_VERTEX_BASE_URL when authType is USE_VERTEX_AI without inferred vertex credentials', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'https://gemini.test.local');
vi.stubEnv('GOOGLE_VERTEX_BASE_URL', 'https://vertex.test.local');
await createContentGenerator(
{
authType: AuthType.USE_VERTEX_AI,
},
mockConfig,
);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: undefined,
vertexai: true,
httpOptions: expect.objectContaining({
baseUrl: 'https://vertex.test.local',
}),
}),
);
});
it('should prefer an explicit baseUrl over GOOGLE_GEMINI_BASE_URL', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'https://env.test.local');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
const config = await createContentGeneratorConfig(
mockConfig,
AuthType.USE_GEMINI,
undefined,
'https://explicit.test.local',
);
await createContentGenerator(config, mockConfig);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
httpOptions: expect.objectContaining({
baseUrl: 'https://explicit.test.local',
}),
}),
);
});
it('should allow localhost baseUrl overrides over http', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
await createContentGenerator(
{
apiKey: 'test-api-key',
authType: AuthType.USE_GEMINI,
baseUrl: 'http://127.0.0.1:8080',
},
mockConfig,
);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
httpOptions: expect.objectContaining({
baseUrl: 'http://127.0.0.1:8080',
}),
}),
);
});
it('should reject invalid custom baseUrl values', async () => {
await expect(
createContentGenerator(
{
apiKey: 'test-api-key',
authType: AuthType.USE_GEMINI,
baseUrl: 'not-a-url',
},
mockConfig,
),
).rejects.toThrow('Invalid custom base URL: not-a-url');
});
it('should reject non-https remote custom baseUrl values', async () => {
await expect(
createContentGenerator(
{
apiKey: 'test-api-key',
authType: AuthType.USE_GEMINI,
baseUrl: 'http://example.com',
},
mockConfig,
),
).rejects.toThrow('Custom base URL must use HTTPS unless it is localhost.');
});
});
describe('createContentGeneratorConfig', () => {
+32 -3
View File
@@ -101,6 +101,21 @@ export type ContentGeneratorConfig = {
customHeaders?: Record<string, string>;
};
const LOCAL_HOSTNAMES = ['localhost', '127.0.0.1', '[::1]'];
function validateBaseUrl(baseUrl: string): void {
let url: URL;
try {
url = new URL(baseUrl);
} catch {
throw new Error(`Invalid custom base URL: ${baseUrl}`);
}
if (url.protocol !== 'https:' && !LOCAL_HOSTNAMES.includes(url.hostname)) {
throw new Error('Custom base URL must use HTTPS unless it is localhost.');
}
}
export async function createContentGeneratorConfig(
config: Config,
authType: AuthType | undefined,
@@ -273,18 +288,32 @@ export async function createContentGenerator(
'x-gemini-api-privileged-user-id': `${installationId}`,
};
}
let baseUrl = config.baseUrl;
if (!baseUrl) {
const envBaseUrl =
config.authType === AuthType.USE_VERTEX_AI
? process.env['GOOGLE_VERTEX_BASE_URL']
: process.env['GOOGLE_GEMINI_BASE_URL'];
if (envBaseUrl) {
validateBaseUrl(envBaseUrl);
baseUrl = envBaseUrl;
}
} else {
validateBaseUrl(baseUrl);
}
const httpOptions: {
baseUrl?: string;
headers: Record<string, string>;
} = { headers };
if (config.baseUrl) {
httpOptions.baseUrl = config.baseUrl;
if (baseUrl) {
httpOptions.baseUrl = baseUrl;
}
const googleGenAI = new GoogleGenAI({
apiKey: config.apiKey === '' ? undefined : config.apiKey,
vertexai: config.vertexai,
vertexai: config.vertexai ?? config.authType === AuthType.USE_VERTEX_AI,
httpOptions,
...(apiVersionEnv && { apiVersion: apiVersionEnv }),
});
+228 -67
View File
@@ -5,6 +5,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fsPromises from 'node:fs/promises';
import { ToolExecutor } from './tool-executor.js';
import {
type Config,
@@ -32,6 +33,7 @@ import {
vi.mock('../utils/fileUtils.js', () => ({
saveTruncatedToolOutput: vi.fn(),
formatTruncatedToolOutput: vi.fn(),
moveToolOutputToFile: vi.fn(),
}));
// Mock executeToolWithHooks
@@ -436,73 +438,10 @@ describe('ToolExecutor', () => {
const response = result.response.responseParts[0]?.functionResponse
?.response as Record<string, unknown>;
// The content should be the *truncated* version returned by the mock formatTruncatedToolOutput
expect(response).toEqual({ output: 'TruncatedContent...' });
expect(result.response.outputFile).toBe('/tmp/truncated_output.txt');
}
});
it('should truncate large MCP tool output with single text Part', async () => {
// 1. Setup Config for Truncation
vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10);
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp');
const mcpToolName = 'get_big_text';
const messageBus = createMockMessageBus();
const mcpTool = new DiscoveredMCPTool(
{} as CallableTool,
'my-server',
'get_big_text',
'A test MCP tool',
{},
messageBus,
);
const invocation = mcpTool.build({});
const longText = 'This is a very long MCP output that should be truncated.';
// 2. Mock execution returning Part[] with single text Part
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
llmContent: [{ text: longText }],
returnDisplay: longText,
});
const scheduledCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
callId: 'call-mcp-trunc',
name: mcpToolName,
args: { query: 'test' },
isClientInitiated: false,
prompt_id: 'prompt-mcp-trunc',
},
tool: mcpTool,
invocation: invocation as unknown as AnyToolInvocation,
startTime: Date.now(),
};
// 3. Execute
const result = await executor.execute({
call: scheduledCall,
signal: new AbortController().signal,
onUpdateToolCall: vi.fn(),
});
// 4. Verify Truncation Logic
expect(fileUtils.saveTruncatedToolOutput).toHaveBeenCalledWith(
longText,
mcpToolName,
'call-mcp-trunc',
expect.any(String),
'test-session-id',
);
expect(fileUtils.formatTruncatedToolOutput).toHaveBeenCalledWith(
longText,
'/tmp/truncated_output.txt',
10,
);
expect(result.status).toBe(CoreToolCallStatus.Success);
if (result.status === CoreToolCallStatus.Success) {
expect(response).toEqual({
output: 'TruncatedContent...',
outputFile: '/tmp/truncated_output.txt',
});
expect(result.response.outputFile).toBe('/tmp/truncated_output.txt');
}
});
@@ -597,6 +536,228 @@ describe('ToolExecutor', () => {
expect(result.status).toBe(CoreToolCallStatus.Success);
});
it('should truncate large output and move file when fullOutputFilePath is provided', async () => {
// 1. Setup Config for Truncation
vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10);
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp');
vi.spyOn(fileUtils, 'moveToolOutputToFile').mockResolvedValue({
outputFile: '/tmp/moved_output.txt',
});
const mockTool = new MockTool({ name: SHELL_TOOL_NAME });
const invocation = mockTool.build({});
const longOutput = 'This is a very long output that should be truncated.';
// 2. Mock execution returning long content AND fullOutputFilePath
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
llmContent: longOutput,
returnDisplay: longOutput,
fullOutputFilePath: '/tmp/temp_full_output.txt',
});
const scheduledCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
callId: 'call-trunc-full',
name: SHELL_TOOL_NAME,
args: { command: 'echo long' },
isClientInitiated: false,
prompt_id: 'prompt-trunc-full',
},
tool: mockTool,
invocation: invocation as unknown as AnyToolInvocation,
startTime: Date.now(),
};
// 3. Execute
const result = await executor.execute({
call: scheduledCall,
signal: new AbortController().signal,
onUpdateToolCall: vi.fn(),
});
// 4. Verify Truncation Logic
expect(fileUtils.moveToolOutputToFile).toHaveBeenCalledWith(
'/tmp/temp_full_output.txt',
SHELL_TOOL_NAME,
'call-trunc-full',
expect.any(String), // temp dir
'test-session-id', // session id from makeFakeConfig
);
expect(fileUtils.formatTruncatedToolOutput).toHaveBeenCalledWith(
longOutput,
'/tmp/moved_output.txt',
10, // threshold (maxChars)
);
expect(result.status).toBe(CoreToolCallStatus.Success);
if (result.status === CoreToolCallStatus.Success) {
const response = result.response.responseParts[0]?.functionResponse
?.response as Record<string, unknown>;
// The content should be the *truncated* version returned by the mock formatTruncatedToolOutput
expect(response).toEqual({
output: 'TruncatedContent...',
outputFile: '/tmp/moved_output.txt',
});
expect(result.response.outputFile).toBe('/tmp/moved_output.txt');
}
});
it('should preserve temporary file when fullOutputFilePath is provided but output is not truncated', async () => {
// 1. Setup Config for Truncation
vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(100);
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp');
vi.spyOn(config, 'getSessionId').mockReturnValue('test-session-id');
const unlinkSpy = vi
.spyOn(fsPromises, 'unlink')
.mockResolvedValue(undefined);
vi.spyOn(fileUtils, 'moveToolOutputToFile').mockResolvedValue({
outputFile: '/tmp/moved_output_short.txt',
});
const mockTool = new MockTool({ name: SHELL_TOOL_NAME });
const invocation = mockTool.build({});
const shortOutput = 'Short';
// 2. Mock execution returning short content AND fullOutputFilePath
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
llmContent: shortOutput,
returnDisplay: shortOutput,
fullOutputFilePath: '/tmp/temp_full_output_short.txt',
});
const scheduledCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
callId: 'call-short-full',
name: SHELL_TOOL_NAME,
args: { command: 'echo short' },
isClientInitiated: false,
prompt_id: 'prompt-short-full',
},
tool: mockTool,
invocation: invocation as unknown as AnyToolInvocation,
startTime: Date.now(),
};
// 3. Execute
const result = await executor.execute({
call: scheduledCall,
signal: new AbortController().signal,
onUpdateToolCall: vi.fn(),
});
// 4. Verify file preservation
expect(fileUtils.moveToolOutputToFile).toHaveBeenCalledWith(
'/tmp/temp_full_output_short.txt',
SHELL_TOOL_NAME,
'call-short-full',
expect.any(String),
'test-session-id',
);
expect(unlinkSpy).not.toHaveBeenCalled();
expect(fileUtils.formatTruncatedToolOutput).not.toHaveBeenCalled();
// We should save it since fullOutputFilePath was provided
expect(result.status).toBe(CoreToolCallStatus.Success);
if (result.status === CoreToolCallStatus.Success) {
const response = result.response.responseParts[0]?.functionResponse
?.response as Record<string, unknown>;
expect(response).toEqual({
output: 'Short',
outputFile: '/tmp/moved_output_short.txt',
});
expect(result.response.outputFile).toBe('/tmp/moved_output_short.txt');
}
unlinkSpy.mockRestore();
});
it('should delete temporary file on error if fullOutputFilePath is provided', async () => {
const unlinkSpy = vi
.spyOn(fsPromises, 'unlink')
.mockResolvedValue(undefined);
const mockTool = new MockTool({ name: 'failTool' });
const invocation = mockTool.build({});
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
llmContent: 'partial',
returnDisplay: 'partial',
fullOutputFilePath: '/tmp/temp_error.txt',
error: { message: 'Tool Failed' },
});
const scheduledCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
callId: 'call-err',
name: 'failTool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-err',
},
tool: mockTool,
invocation: invocation as unknown as AnyToolInvocation,
startTime: Date.now(),
};
const result = await executor.execute({
call: scheduledCall,
signal: new AbortController().signal,
onUpdateToolCall: vi.fn(),
});
expect(unlinkSpy).toHaveBeenCalledWith('/tmp/temp_error.txt');
expect(result.status).toBe(CoreToolCallStatus.Error);
unlinkSpy.mockRestore();
});
it('should delete temporary file on abort if fullOutputFilePath is provided', async () => {
const unlinkSpy = vi
.spyOn(fsPromises, 'unlink')
.mockResolvedValue(undefined);
const mockTool = new MockTool({ name: 'slowTool' });
const invocation = mockTool.build({});
const controller = new AbortController();
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockImplementation(
async () => {
controller.abort();
return {
llmContent: 'partial',
returnDisplay: 'partial',
fullOutputFilePath: '/tmp/temp_abort.txt',
};
},
);
const scheduledCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
callId: 'call-abort',
name: 'slowTool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-abort',
},
tool: mockTool,
invocation: invocation as unknown as AnyToolInvocation,
startTime: Date.now(),
};
const result = await executor.execute({
call: scheduledCall,
signal: controller.signal,
onUpdateToolCall: vi.fn(),
});
expect(unlinkSpy).toHaveBeenCalledWith('/tmp/temp_abort.txt');
expect(result.status).toBe(CoreToolCallStatus.Cancelled);
unlinkSpy.mockRestore();
});
it('should report execution ID updates for backgroundable tools', async () => {
// 1. Setup ShellToolInvocation
const messageBus = createMockMessageBus();
+113 -22
View File
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fsPromises from 'node:fs/promises';
import { debugLogger } from '../utils/debugLogger.js';
import {
ToolErrorType,
ToolOutputTruncatedEvent,
@@ -24,6 +26,7 @@ import { executeToolWithHooks } from '../core/coreToolHookTriggers.js';
import {
saveTruncatedToolOutput,
formatTruncatedToolOutput,
moveToolOutputToFile,
} from '../utils/fileUtils.js';
import { convertToFunctionResponse } from '../utils/generateContentResponseUtilities.js';
import {
@@ -138,6 +141,16 @@ export class ToolExecutor {
}
if (signal.aborted) {
if (toolResult.fullOutputFilePath) {
await fsPromises
.unlink(toolResult.fullOutputFilePath)
.catch((error) => {
debugLogger.warn(
`Failed to delete temporary tool output file on abort: ${toolResult.fullOutputFilePath}`,
error,
);
});
}
completedToolCall = await this.createCancelledResult(
call,
'User cancelled tool execution.',
@@ -149,6 +162,16 @@ export class ToolExecutor {
toolResult,
);
} else {
if (toolResult.fullOutputFilePath) {
await fsPromises
.unlink(toolResult.fullOutputFilePath)
.catch((error) => {
debugLogger.warn(
`Failed to delete temporary tool output file on error: ${toolResult.fullOutputFilePath}`,
error,
);
});
}
const displayText =
typeof toolResult.returnDisplay === 'string'
? toolResult.returnDisplay
@@ -197,33 +220,80 @@ export class ToolExecutor {
private async truncateOutputIfNeeded(
call: ToolCall,
content: PartListUnion,
fullOutputFilePath?: string,
): Promise<{ truncatedContent: PartListUnion; outputFile?: string }> {
const toolName = call.request.name;
const callId = call.request.callId;
if (this.config.isContextManagementEnabled()) {
const distiller = new ToolOutputDistillationService(
this.config,
this.context.geminiClient,
this.context.promptId,
);
return distiller.distill(call.request.name, call.request.callId, content);
const result = await distiller.distill(
call.request.name,
call.request.callId,
content,
);
let finalOutputFile = result.outputFile;
if (fullOutputFilePath) {
const { outputFile: movedPath } = await moveToolOutputToFile(
fullOutputFilePath,
toolName,
callId,
this.config.storage.getProjectTempDir(),
this.config.getSessionId(),
);
finalOutputFile = movedPath;
if (result.outputFile) {
try {
await fsPromises.unlink(result.outputFile);
} catch (error) {
debugLogger.warn(
`Failed to delete distiller's temporary tool output file: ${result.outputFile}`,
error,
);
}
}
}
return {
truncatedContent: result.truncatedContent,
outputFile: finalOutputFile,
};
}
const toolName = call.request.name;
const callId = call.request.callId;
let outputFile: string | undefined;
if (fullOutputFilePath) {
const { outputFile: movedPath } = await moveToolOutputToFile(
fullOutputFilePath,
toolName,
callId,
this.config.storage.getProjectTempDir(),
this.config.getSessionId(),
);
outputFile = movedPath;
}
if (typeof content === 'string' && toolName === SHELL_TOOL_NAME) {
const threshold = this.config.getTruncateToolOutputThreshold();
if (threshold > 0 && content.length > threshold) {
const originalContentLength = content.length;
const { outputFile: savedPath } = await saveTruncatedToolOutput(
content,
toolName,
callId,
this.config.storage.getProjectTempDir(),
this.context.promptId,
);
outputFile = savedPath;
if (!outputFile) {
const { outputFile: writtenPath } = await saveTruncatedToolOutput(
content,
toolName,
callId,
this.config.storage.getProjectTempDir(),
this.context.promptId,
);
outputFile = writtenPath;
}
const truncatedContent = formatTruncatedToolOutput(
content,
outputFile,
@@ -255,14 +325,16 @@ export class ToolExecutor {
if (threshold > 0 && textContent.length > threshold) {
const originalContentLength = textContent.length;
const { outputFile: savedPath } = await saveTruncatedToolOutput(
textContent,
toolName,
callId,
this.config.storage.getProjectTempDir(),
this.context.promptId,
);
outputFile = savedPath;
if (!outputFile) {
const { outputFile: savedPath } = await saveTruncatedToolOutput(
textContent,
toolName,
callId,
this.config.storage.getProjectTempDir(),
this.context.promptId,
);
outputFile = savedPath;
}
const truncatedText = formatTruncatedToolOutput(
textContent,
outputFile,
@@ -314,7 +386,11 @@ export class ToolExecutor {
// Attempt to truncate and save output if we have content, even in cancellation case
// This is to handle cases where the tool may have produced output before cancellation
const { truncatedContent: output, outputFile: truncatedOutputFile } =
await this.truncateOutputIfNeeded(call, toolResult?.llmContent);
await this.truncateOutputIfNeeded(
call,
toolResult.llmContent,
toolResult.fullOutputFilePath,
);
outputFile = truncatedOutputFile;
responseParts = convertToFunctionResponse(
@@ -323,6 +399,7 @@ export class ToolExecutor {
output,
this.config.getActiveModel(),
this.config,
outputFile,
);
// Inject the cancellation error into the response object
@@ -332,6 +409,16 @@ export class ToolExecutor {
respObj['error'] = errorMessage;
}
} else {
if (toolResult?.fullOutputFilePath) {
try {
await fsPromises.unlink(toolResult.fullOutputFilePath);
} catch (error) {
debugLogger.warn(
`Failed to delete temporary tool output file: ${toolResult.fullOutputFilePath}`,
error,
);
}
}
responseParts = [
{
functionResponse: {
@@ -369,8 +456,11 @@ export class ToolExecutor {
toolResult: ToolResult,
): Promise<SuccessfulToolCall> {
const { truncatedContent: content, outputFile } =
await this.truncateOutputIfNeeded(call, toolResult.llmContent);
await this.truncateOutputIfNeeded(
call,
toolResult.llmContent,
toolResult.fullOutputFilePath,
);
const toolName = call.request.originalRequestName || call.request.name;
const callId = call.request.callId;
@@ -380,6 +470,7 @@ export class ToolExecutor {
content,
this.config.getActiveModel(),
this.config,
outputFile,
);
const successResponse: ToolCallResponseInfo = {
@@ -27,6 +27,7 @@ export interface ExecutionResult {
pid: number | undefined;
executionMethod: ExecutionMethod;
backgrounded?: boolean;
fullOutputFilePath?: string;
}
export interface ExecutionHandle {
@@ -35,6 +36,14 @@ export interface ExecutionHandle {
}
export type ExecutionOutputEvent =
| {
type: 'raw_data';
chunk: string;
}
| {
type: 'file_data';
chunk: string;
}
| {
type: 'data';
chunk: string | AnsiOutput;
@@ -33,7 +33,16 @@ const mockIsBinary = vi.hoisted(() => vi.fn());
const mockPlatform = vi.hoisted(() => vi.fn());
const mockHomedir = vi.hoisted(() => vi.fn());
const mockMkdirSync = vi.hoisted(() => vi.fn());
const mockCreateWriteStream = vi.hoisted(() => vi.fn());
const mockCreateWriteStream = vi.hoisted(() =>
vi.fn().mockReturnValue({
write: vi.fn(),
end: vi.fn().mockImplementation((cb?: () => void) => {
if (cb) cb();
}),
destroy: vi.fn(),
closed: false,
}),
);
const mockGetPty = vi.hoisted(() => vi.fn());
const mockSerializeTerminalToObject = vi.hoisted(() => vi.fn());
const mockResolveExecutable = vi.hoisted(() => vi.fn());
@@ -92,6 +101,7 @@ vi.mock('node:os', () => ({
default: {
platform: mockPlatform,
homedir: mockHomedir,
tmpdir: () => '/tmp',
constants: {
signals: {
SIGTERM: 15,
@@ -208,6 +218,15 @@ describe('ShellExecutionService', () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreateWriteStream.mockReturnValue({
write: vi.fn(),
end: vi.fn().mockImplementation((cb?: () => void) => {
if (cb) cb();
}),
destroy: vi.fn(),
closed: false,
on: vi.fn(),
});
ExecutionLifecycleService.resetForTest();
ShellExecutionService.resetForTest();
mockSerializeTerminalToObject.mockReturnValue([]);
@@ -489,6 +508,7 @@ describe('ShellExecutionService', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
headlessTerminal: mockHeadlessTerminal as any,
command: 'some-command',
lastCommittedLine: -1,
});
});
@@ -620,7 +640,7 @@ describe('ShellExecutionService', () => {
});
it('should handle a synchronous spawn error', async () => {
mockGetPty.mockImplementation(() => null);
mockGetPty.mockResolvedValue(null);
mockCpSpawn.mockImplementation(() => {
throw new Error('Simulated PTY spawn error');
@@ -724,7 +744,13 @@ describe('ShellExecutionService', () => {
});
describe('Backgrounding', () => {
let mockWriteStream: { write: Mock; end: Mock; on: Mock };
let mockWriteStream: {
write: Mock;
end: Mock;
on: Mock;
destroy: Mock;
closed: boolean;
};
let mockBgChildProcess: EventEmitter & Partial<ChildProcess>;
beforeEach(async () => {
@@ -732,6 +758,8 @@ describe('ShellExecutionService', () => {
write: vi.fn(),
end: vi.fn().mockImplementation((cb) => cb?.()),
on: vi.fn(),
destroy: vi.fn(),
closed: false,
};
mockMkdirSync.mockReturnValue(undefined);
@@ -989,6 +1017,8 @@ describe('ShellExecutionService', () => {
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
});
// We don't check result here because result is not available in the test body as written
// The test body doesn't capture the return value of simulateExecution correctly for this assertion.
expect(onOutputEventMock).toHaveBeenCalledTimes(4);
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
type: 'binary_detected',
@@ -1301,7 +1331,7 @@ describe('ShellExecutionService child_process fallback', () => {
const { result, handle } = await simulateExecution('ls -l', (cp) => {
cp.stdout?.emit('data', Buffer.from('file1.txt\n'));
cp.stderr?.emit('data', Buffer.from('a warning'));
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
cp.emit('close', 0, null);
});
@@ -1338,7 +1368,7 @@ describe('ShellExecutionService child_process fallback', () => {
it('should strip ANSI color codes from output', async () => {
const { result } = await simulateExecution('ls --color=auto', (cp) => {
cp.stdout?.emit('data', Buffer.from('a\u001b[31mred\u001b[0mword'));
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
cp.emit('close', 0, null);
});
@@ -1359,7 +1389,7 @@ describe('ShellExecutionService child_process fallback', () => {
const multiByteChar = Buffer.from('你好', 'utf-8');
cp.stdout?.emit('data', multiByteChar.slice(0, 2));
cp.stdout?.emit('data', multiByteChar.slice(2));
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
cp.emit('close', 0, null);
});
expect(result.output.trim()).toBe('你好');
@@ -1367,7 +1397,7 @@ describe('ShellExecutionService child_process fallback', () => {
it('should handle commands with no output', async () => {
const { result } = await simulateExecution('touch file', (cp) => {
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
cp.emit('close', 0, null);
});
@@ -1389,7 +1419,7 @@ describe('ShellExecutionService child_process fallback', () => {
cp.stdout?.emit('data', Buffer.from(chunk1));
cp.stdout?.emit('data', Buffer.from(chunk2));
cp.stdout?.emit('data', Buffer.from(chunk3));
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
});
const truncationMessage =
@@ -1414,7 +1444,7 @@ describe('ShellExecutionService child_process fallback', () => {
it('should capture a non-zero exit code and format output correctly', async () => {
const { result } = await simulateExecution('a-bad-command', (cp) => {
cp.stderr?.emit('data', Buffer.from('command not found'));
cp.emit('exit', 127, null);
cp.emit('close', 127, null);
cp.emit('close', 127, null);
});
@@ -1425,7 +1455,7 @@ describe('ShellExecutionService child_process fallback', () => {
it('should capture a termination signal', async () => {
const { result } = await simulateExecution('long-process', (cp) => {
cp.emit('exit', null, 'SIGTERM');
cp.emit('close', null, 'SIGTERM');
cp.emit('close', null, 'SIGTERM');
});
@@ -1437,7 +1467,7 @@ describe('ShellExecutionService child_process fallback', () => {
const spawnError = new Error('spawn EACCES');
const { result } = await simulateExecution('protected-cmd', (cp) => {
cp.emit('error', spawnError);
cp.emit('exit', 1, null);
cp.emit('close', 1, null);
cp.emit('close', 1, null);
});
@@ -1483,11 +1513,11 @@ describe('ShellExecutionService child_process fallback', () => {
await new Promise(process.nextTick);
await new Promise(process.nextTick);
if (expectedExit.signal) {
cp.emit('exit', null, expectedExit.signal);
cp.emit('close', null, expectedExit.signal);
cp.emit('close', null, expectedExit.signal);
}
if (typeof expectedExit.code === 'number') {
cp.emit('exit', expectedExit.code, null);
cp.emit('close', expectedExit.code, null);
cp.emit('close', expectedExit.code, null);
}
},
@@ -1556,7 +1586,7 @@ describe('ShellExecutionService child_process fallback', () => {
);
// Finally, simulate the process exiting and await the result
mockChildProcess.emit('exit', null, 'SIGKILL');
mockChildProcess.emit('close', null, 'SIGKILL');
mockChildProcess.emit('close', null, 'SIGKILL');
const result = await handle.result;
@@ -1576,9 +1606,11 @@ describe('ShellExecutionService child_process fallback', () => {
await simulateExecution('cat image.png', (cp) => {
cp.stdout?.emit('data', binaryChunk1);
cp.stdout?.emit('data', binaryChunk2);
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
});
// We don't check result here because result is not available in the test body as written
// The test body doesn't capture the return value of simulateExecution correctly for this assertion.
expect(onOutputEventMock).toHaveBeenCalledTimes(4);
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
type: 'binary_detected',
@@ -1604,7 +1636,6 @@ describe('ShellExecutionService child_process fallback', () => {
await simulateExecution('cat mixed_file', (cp) => {
cp.stdout?.emit('data', Buffer.from([0x00, 0x01, 0x02]));
cp.stdout?.emit('data', Buffer.from('more text'));
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
});
@@ -1640,7 +1671,7 @@ describe('ShellExecutionService child_process fallback', () => {
it('should use powershell.exe on Windows', async () => {
mockPlatform.mockReturnValue('win32');
await simulateExecution('dir "foo bar"', (cp) => {
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
});
expect(mockCpSpawn).toHaveBeenCalledWith(
@@ -1657,7 +1688,7 @@ describe('ShellExecutionService child_process fallback', () => {
it('should use bash and detached process group on Linux', async () => {
mockPlatform.mockReturnValue('linux');
await simulateExecution('ls "foo bar"', (cp) => {
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
});
expect(mockCpSpawn).toHaveBeenCalledWith(
@@ -1771,7 +1802,7 @@ describe('ShellExecutionService execution method selection', () => {
);
// Simulate exit to allow promise to resolve
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
const result = await handle.result;
expect(mockGetPty).not.toHaveBeenCalled();
@@ -1794,7 +1825,7 @@ describe('ShellExecutionService execution method selection', () => {
);
// Simulate exit to allow promise to resolve
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
const result = await handle.result;
expect(mockGetPty).toHaveBeenCalled();
@@ -1860,10 +1891,6 @@ describe('ShellExecutionService environment variables', () => {
// Small delay to allow async ops to complete
setTimeout(() => mockPtyProcess.emit('exit', { exitCode, signal }), 0);
});
mockChildProcess.on('exit', (code, signal) => {
// Small delay to allow async ops to complete
setTimeout(() => mockChildProcess.emit('close', code, signal), 0);
});
});
afterEach(() => {
@@ -1926,7 +1953,7 @@ describe('ShellExecutionService environment variables', () => {
expect(cpEnv).toHaveProperty('GEMINI_CLI_TEST_VAR', 'test-value');
// Ensure child_process exits
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
mockChildProcess.emit('close', 0, null);
await new Promise(process.nextTick);
});
@@ -1986,7 +2013,7 @@ describe('ShellExecutionService environment variables', () => {
expect(cpEnv).toHaveProperty('GEMINI_CLI_TEST_VAR', 'test-value');
// Ensure child_process exits
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
mockChildProcess.emit('close', 0, null);
await new Promise(process.nextTick);
});
@@ -2034,7 +2061,7 @@ describe('ShellExecutionService environment variables', () => {
expect(cpEnv).toHaveProperty('GEMINI_CLI', '1');
// Ensure child_process exits
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
mockChildProcess.emit('close', 0, null);
await new Promise(process.nextTick);
});
@@ -2091,7 +2118,7 @@ describe('ShellExecutionService environment variables', () => {
);
// Clean up
mockChild.emit('exit', 0, null);
mockChild.emit('close', 0, null);
mockChild.emit('close', 0, null);
await handle.result;
});
@@ -2140,7 +2167,7 @@ describe('ShellExecutionService environment variables', () => {
expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_2', '');
// Ensure child_process exits
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
mockChildProcess.emit('close', 0, null);
await new Promise(process.nextTick);
@@ -2180,7 +2207,7 @@ describe('ShellExecutionService environment variables', () => {
expect(cpEnv).not.toHaveProperty('GIT_CONFIG_COUNT');
// Ensure child_process exits
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
mockChildProcess.emit('close', 0, null);
await new Promise(process.nextTick);
@@ -12,6 +12,7 @@ import type { Writable } from 'node:stream';
import os from 'node:os';
import fs, { mkdirSync } from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import type { IPty } from '@lydell/node-pty';
import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js';
import {
@@ -120,6 +121,8 @@ interface ActivePty {
maxSerializedLines?: number;
command: string;
sessionId?: string;
lastSerializedOutput?: AnsiOutput;
lastCommittedLine: number;
}
interface ActiveChildProcess {
@@ -148,6 +151,48 @@ const findLastContentLine = (
return -1;
};
const emitPendingLines = (
activePty: ActivePty,
pid: number,
onOutputEvent: (event: ShellOutputEvent) => void,
forceAll = false,
) => {
const buffer = activePty.headlessTerminal.buffer.active;
const limit = forceAll ? buffer.length : buffer.baseY;
let chunks = '';
for (let i = activePty.lastCommittedLine + 1; i < limit; i++) {
const line = buffer.getLine(i);
if (!line) continue;
let trimRight = true;
let isNextLineWrapped = false;
if (i + 1 < buffer.length) {
const nextLine = buffer.getLine(i + 1);
if (nextLine?.isWrapped) {
isNextLineWrapped = true;
trimRight = false;
}
}
const lineContent = line.translateToString(trimRight);
chunks += lineContent;
if (!isNextLineWrapped) {
chunks += '\n';
}
}
if (chunks.length > 0) {
const event: ShellOutputEvent = {
type: 'file_data',
chunk: chunks,
};
onOutputEvent(event);
ExecutionLifecycleService.emitEvent(pid, event);
activePty.lastCommittedLine = limit - 1;
}
};
const getFullBufferText = (terminal: pkg.Terminal, startLine = 0): string => {
const buffer = terminal.buffer.active;
const lines: string[] = [];
@@ -326,32 +371,110 @@ export class ShellExecutionService {
shouldUseNodePty: boolean,
shellExecutionConfig: ShellExecutionConfig,
): Promise<ShellExecutionHandle> {
const outputFileName = `gemini_shell_output_${crypto.randomBytes(6).toString('hex')}.log`;
const outputFilePath = path.join(os.tmpdir(), outputFileName);
const outputStream = fs.createWriteStream(outputFilePath);
let totalBytesWritten = 0;
const interceptedOnOutputEvent = (event: ShellOutputEvent) => {
switch (event.type) {
case 'raw_data':
break;
case 'file_data':
outputStream.write(event.chunk);
totalBytesWritten += Buffer.byteLength(event.chunk);
break;
case 'binary_detected':
case 'binary_progress':
break;
default:
break;
}
onOutputEvent(event);
};
let handlePromise: Promise<ShellExecutionHandle>;
if (shouldUseNodePty) {
const ptyInfo = await getPty();
if (ptyInfo) {
try {
return await this.executeWithPty(
handlePromise = getPty().then((ptyInfo) => {
if (ptyInfo) {
return this.executeWithPty(
commandToExecute,
cwd,
onOutputEvent,
interceptedOnOutputEvent,
abortSignal,
shellExecutionConfig,
ptyInfo,
).catch(() =>
this.childProcessFallback(
commandToExecute,
cwd,
interceptedOnOutputEvent,
abortSignal,
shellExecutionConfig,
shouldUseNodePty,
),
);
} catch {
// Fallback to child_process
}
}
return this.childProcessFallback(
commandToExecute,
cwd,
interceptedOnOutputEvent,
abortSignal,
shellExecutionConfig,
shouldUseNodePty,
);
});
} else {
handlePromise = this.childProcessFallback(
commandToExecute,
cwd,
interceptedOnOutputEvent,
abortSignal,
shellExecutionConfig,
shouldUseNodePty,
);
}
return this.childProcessFallback(
commandToExecute,
cwd,
onOutputEvent,
abortSignal,
shellExecutionConfig,
shouldUseNodePty,
);
const handle = await handlePromise;
const wrappedResultPromise = handle.result
.then(async (result) => {
await new Promise<void>((resolve) => {
outputStream.end(resolve);
});
// The threshold logic is handled later by ToolExecutor/caller, so we just return the full file path if anything was written
if (
totalBytesWritten > 0 &&
!result.backgrounded &&
!abortSignal.aborted &&
!result.error
) {
return {
...result,
fullOutputFilePath: outputFilePath,
};
} else {
if (!outputStream.closed) {
outputStream.destroy();
}
await fs.promises.unlink(outputFilePath).catch(() => undefined);
return result;
}
})
.catch(async (err) => {
if (!outputStream.closed) {
outputStream.destroy();
}
await fs.promises.unlink(outputFilePath).catch(() => undefined);
throw err;
});
return {
pid: handle.pid,
result: wrappedResultPromise,
};
}
private static appendAndTruncate(
@@ -661,6 +784,24 @@ export class ShellExecutionService {
}
if (decodedChunk) {
const rawEvent: ShellOutputEvent = {
type: 'raw_data',
chunk: decodedChunk,
};
onOutputEvent(rawEvent);
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, rawEvent);
}
const fileEvent: ShellOutputEvent = {
type: 'file_data',
chunk: stripAnsi(decodedChunk),
};
onOutputEvent(fileEvent);
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, fileEvent);
}
const event: ShellOutputEvent = {
type: 'data',
chunk: decodedChunk,
@@ -772,7 +913,7 @@ export class ShellExecutionService {
abortSignal.addEventListener('abort', abortHandler, { once: true });
child.on('exit', (code, signal) => {
child.on('close', (code, signal) => {
handleExit(code, signal);
});
@@ -784,6 +925,15 @@ export class ShellExecutionService {
if (remaining) {
state.output += remaining;
if (isStreamingRawContent) {
const rawEvent: ShellOutputEvent = {
type: 'raw_data',
chunk: remaining,
};
onOutputEvent(rawEvent);
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, rawEvent);
}
const event: ShellOutputEvent = {
type: 'data',
chunk: remaining,
@@ -792,6 +942,15 @@ export class ShellExecutionService {
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, event);
}
const fileEvent: ShellOutputEvent = {
type: 'file_data',
chunk: stripAnsi(remaining),
};
onOutputEvent(fileEvent);
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, fileEvent);
}
}
}
}
@@ -800,6 +959,15 @@ export class ShellExecutionService {
if (remaining) {
state.output += remaining;
if (isStreamingRawContent) {
const rawEvent: ShellOutputEvent = {
type: 'raw_data',
chunk: remaining,
};
onOutputEvent(rawEvent);
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, rawEvent);
}
const event: ShellOutputEvent = {
type: 'data',
chunk: remaining,
@@ -808,6 +976,15 @@ export class ShellExecutionService {
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, event);
}
const fileEvent: ShellOutputEvent = {
type: 'file_data',
chunk: stripAnsi(remaining),
};
onOutputEvent(fileEvent);
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, fileEvent);
}
}
}
}
@@ -934,6 +1111,7 @@ export class ShellExecutionService {
maxSerializedLines: shellExecutionConfig.maxSerializedLines,
command: shellExecutionConfig.originalCommand ?? commandToExecute,
sessionId: shellExecutionConfig.sessionId,
lastCommittedLine: -1,
});
const result = ExecutionLifecycleService.attachExecution(ptyPid, {
@@ -1099,10 +1277,38 @@ export class ShellExecutionService {
}, 68);
};
headlessTerminal.onScroll(() => {
let lastYdisp = 0;
let hasReachedMax = false;
const scrollbackLimit =
shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT;
headlessTerminal.onScroll((ydisp) => {
if (!isWriting) {
render();
}
if (
ydisp === scrollbackLimit &&
lastYdisp === scrollbackLimit &&
hasReachedMax
) {
const activePty = this.activePtys.get(ptyPid);
if (activePty) {
activePty.lastCommittedLine--;
}
}
if (
ydisp === scrollbackLimit &&
headlessTerminal.buffer.active.length === scrollbackLimit + rows
) {
hasReachedMax = true;
}
lastYdisp = ydisp;
const activePtyForEmit = this.activePtys.get(ptyPid);
if (activePtyForEmit) {
emitPendingLines(activePtyForEmit, ptyPid, onOutputEvent);
}
});
const handleOutput = (data: Buffer) => {
@@ -1187,6 +1393,11 @@ export class ShellExecutionService {
render(true);
cmdCleanup?.();
const activePty = ShellExecutionService.activePtys.get(ptyPid);
if (activePty && isStreamingRawContent) {
emitPendingLines(activePty, ptyPid, onOutputEvent, true);
}
const event: ShellOutputEvent = {
type: 'exit',
exitCode,
@@ -1490,6 +1701,7 @@ export class ShellExecutionService {
startLine,
endLine,
);
activePty.lastSerializedOutput = bufferData;
const event: ShellOutputEvent = { type: 'data', chunk: bufferData };
ExecutionLifecycleService.emitEvent(pid, event);
}
@@ -17,13 +17,18 @@ import {
_resetGlobalMemoryMonitorForTests,
} from './memory-monitor.js';
import type { Config } from '../config/config.js';
import { recordMemoryUsage, isPerformanceMonitoringActive } from './metrics.js';
import {
recordMemoryUsage,
recordCpuUsage,
isPerformanceMonitoringActive,
} from './metrics.js';
import { HighWaterMarkTracker } from './high-water-mark-tracker.js';
import { RateLimiter } from './rate-limiter.js';
// Mock dependencies
vi.mock('./metrics.js', () => ({
recordMemoryUsage: vi.fn(),
recordCpuUsage: vi.fn(),
isPerformanceMonitoringActive: vi.fn(),
MemoryMetricType: {
HEAP_USED: 'heap_used',
@@ -50,6 +55,7 @@ vi.mock('node:process', () => ({
}));
const mockRecordMemoryUsage = vi.mocked(recordMemoryUsage);
const mockRecordCpuUsage = vi.mocked(recordCpuUsage);
const mockIsPerformanceMonitoringActive = vi.mocked(
isPerformanceMonitoringActive,
);
@@ -192,6 +198,13 @@ describe('MemoryMonitor', () => {
component: 'test_context',
},
);
expect(mockRecordCpuUsage).toHaveBeenCalledWith(
mockConfig,
expect.any(Number),
{
component: 'test_context',
},
);
});
it('should not record metrics when performance monitoring is inactive', () => {
@@ -12,6 +12,7 @@ import { isUserActive } from './activity-detector.js';
import { HighWaterMarkTracker } from './high-water-mark-tracker.js';
import {
recordMemoryUsage,
recordCpuUsage,
MemoryMetricType,
isPerformanceMonitoringActive,
} from './metrics.js';
@@ -37,6 +38,7 @@ export class MemoryMonitor {
private intervalId: NodeJS.Timeout | null = null;
private isRunning = false;
private lastSnapshot: MemorySnapshot | null = null;
private lastCpuUsage: NodeJS.CpuUsage | null = null;
private monitoringInterval: number = 10000;
private highWaterMarkTracker: HighWaterMarkTracker;
private rateLimiter: RateLimiter;
@@ -191,6 +193,13 @@ export class MemoryMonitor {
memory_type: MemoryMetricType.RSS,
component: context,
});
// Record delta CPU usage (in microseconds)
const cpuUsage = process.cpuUsage(this.lastCpuUsage ?? undefined);
this.lastCpuUsage = process.cpuUsage();
recordCpuUsage(config, cpuUsage.user + cpuUsage.system, {
component: context,
});
}
this.lastSnapshot = snapshot;
+1
View File
@@ -119,6 +119,7 @@ describe('ShellTool', () => {
getDebugMode: vi.fn().mockReturnValue(false),
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined),
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(40000),
getWorkspaceContext: vi
.fn()
.mockReturnValue(new WorkspaceContext(tempRootDir)),
+107 -20
View File
@@ -9,7 +9,7 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { debugLogger } from '../index.js';
import { debugLogger } from '../utils/debugLogger.js';
import { type SandboxPermissions } from '../services/sandboxManager.js';
import { ToolErrorType } from './tool-error.js';
import {
@@ -34,7 +34,10 @@ import {
type ShellOutputEvent,
} from '../services/shellExecutionService.js';
import { formatBytes } from '../utils/formatters.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import {
serializeAnsiOutputToText,
type AnsiOutput,
} from '../utils/terminalSerializer.js';
import {
getCommandRoots,
initializeShellParsers,
@@ -464,6 +467,14 @@ export class ShellToolInvocation extends BaseToolInvocation<
const onAbort = () => combinedController.abort();
const outputFileName = `gemini_shell_output_${crypto.randomBytes(6).toString('hex')}.log`;
const projectTempDir = this.context.config.storage.getProjectTempDir();
fs.mkdirSync(projectTempDir, { recursive: true });
const outputFilePath = path.join(projectTempDir, outputFileName);
const outputStream = fs.createWriteStream(outputFilePath);
let fullOutputReturned = false;
try {
// pgrep is not available on Windows, so we can't get background PIDs
const commandToExecute = this.wrapCommandForPgrep(
@@ -490,6 +501,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
let cumulativeOutput: string | AnsiOutput = '';
let lastUpdateTime = Date.now();
let isBinaryStream = false;
let totalBytesWritten = 0;
const resetTimeout = () => {
if (timeoutMs <= 0) {
@@ -515,31 +527,65 @@ export class ShellToolInvocation extends BaseToolInvocation<
cwd,
(event: ShellOutputEvent) => {
resetTimeout(); // Reset timeout on any event
if (!updateOutput) {
return;
}
let shouldUpdate = false;
switch (event.type) {
case 'raw_data':
// We do not write raw data to the file to avoid spurious escape codes.
// We rely on 'file_data' for the clean output stream.
break;
case 'file_data':
totalBytesWritten += Buffer.byteLength(event.chunk);
outputStream.write(event.chunk);
break;
case 'data':
if (isBinaryStream) break;
cumulativeOutput = event.chunk;
shouldUpdate = true;
if (typeof event.chunk === 'string') {
if (typeof cumulativeOutput === 'string') {
// Accumulate string chunks, capped at 16MB (same as ShellExecutionService)
const MAX_BUFFER = 16 * 1024 * 1024;
if (
cumulativeOutput.length + event.chunk.length >
MAX_BUFFER
) {
cumulativeOutput = (cumulativeOutput + event.chunk).slice(
-MAX_BUFFER,
);
} else {
cumulativeOutput += event.chunk;
}
} else {
// In case of mode switch (unlikely)
cumulativeOutput = event.chunk;
}
} else {
// Snapshots (AnsiOutput) always replace
cumulativeOutput = event.chunk;
}
if (updateOutput && !this.params.is_background) {
updateOutput(cumulativeOutput);
lastUpdateTime = Date.now();
}
break;
case 'binary_detected':
isBinaryStream = true;
cumulativeOutput =
'[Binary output detected. Halting stream...]';
shouldUpdate = true;
if (updateOutput && !this.params.is_background) {
updateOutput(cumulativeOutput);
}
break;
case 'binary_progress':
isBinaryStream = true;
cumulativeOutput = `[Receiving binary output... ${formatBytes(
event.bytesReceived,
)} received]`;
if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) {
shouldUpdate = true;
if (
updateOutput &&
!this.params.is_background &&
Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS
) {
updateOutput(cumulativeOutput);
lastUpdateTime = Date.now();
}
break;
case 'exit':
@@ -548,11 +594,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
throw new Error('An unhandled ShellOutputEvent was found.');
}
}
if (shouldUpdate && !this.params.is_background) {
updateOutput(cumulativeOutput);
lastUpdateTime = Date.now();
}
},
combinedController.signal,
this.context.config.getEnableInteractiveShell(),
@@ -615,9 +656,14 @@ export class ShellToolInvocation extends BaseToolInvocation<
await new Promise((resolve) => setTimeout(resolve, delay));
if (!completed) {
const initialOutputText =
typeof cumulativeOutput === 'string'
? cumulativeOutput
: serializeAnsiOutputToText(cumulativeOutput);
// Return early with initial output if still running
return {
llmContent: `Command is running in background. PID: ${pid}. Initial output:\n${cumulativeOutput}`,
llmContent: `Command is running in background. PID: ${pid}. Initial output:\n${initialOutputText}`,
returnDisplay: `Background process started with PID ${pid}.`,
};
}
@@ -625,6 +671,15 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
const result = await resultPromise;
await new Promise<void>((resolve, reject) => {
outputStream.on('error', reject);
outputStream.end(resolve);
});
// Ensure the stream is fully closed before we proceed
if (!outputStream.closed) {
await new Promise<void>((resolve) => outputStream.on('close', resolve));
}
const backgroundPIDs: number[] = [];
if (os.platform() !== 'win32') {
@@ -918,21 +973,46 @@ export class ShellToolInvocation extends BaseToolInvocation<
this.context.geminiClient,
signal,
);
return {
const threshold = this.context.config.getTruncateToolOutputThreshold();
const fullOutputFilePath =
threshold > 0 && totalBytesWritten >= threshold
? outputFilePath
: undefined;
const toolResult: ToolResult = {
llmContent: summary,
returnDisplay,
fullOutputFilePath,
...executionError,
};
if (toolResult.fullOutputFilePath) {
fullOutputReturned = true;
}
return toolResult;
}
return {
const threshold = this.context.config.getTruncateToolOutputThreshold();
const fullOutputFilePath =
threshold > 0 && totalBytesWritten >= threshold
? outputFilePath
: undefined;
const toolResult: ToolResult = {
llmContent,
returnDisplay,
data,
fullOutputFilePath,
...executionError,
};
if (toolResult.fullOutputFilePath) {
fullOutputReturned = true;
}
return toolResult;
} finally {
if (timeoutTimer) clearTimeout(timeoutTimer);
if (!outputStream.closed) {
outputStream.destroy();
}
signal.removeEventListener('abort', onAbort);
timeoutController.signal.removeEventListener('abort', onAbort);
try {
@@ -940,6 +1020,13 @@ export class ShellToolInvocation extends BaseToolInvocation<
} catch {
// Ignore errors during unlink
}
if (!fullOutputReturned) {
try {
await fsPromises.unlink(outputFilePath);
} catch {
// Ignore errors during unlink
}
}
}
}
}
+7
View File
@@ -776,6 +776,13 @@ export interface ToolResult {
name: string;
args: Record<string, unknown>;
};
/**
* Optional path to a file containing the full, non-truncated output of the tool.
* If provided, the scheduler may use this file for long-term storage and
* reference it in the conversation history if the output is truncated.
*/
fullOutputFilePath?: string;
}
/**
+42
View File
@@ -677,6 +677,48 @@ ${head}
${tail}`;
}
/**
* Moves tool output from a source path to a temporary file for later retrieval.
*/
export async function moveToolOutputToFile(
sourcePath: string,
toolName: string,
id: string | number, // Accept string (callId) or number (truncationId)
projectTempDir: string,
sessionId?: string,
): Promise<{ outputFile: string }> {
const safeToolName = sanitizeFilenamePart(toolName).toLowerCase();
const safeId = sanitizeFilenamePart(id.toString()).toLowerCase();
const fileName = safeId.startsWith(safeToolName)
? `${safeId}.txt`
: `${safeToolName}_${safeId}.txt`;
let toolOutputDir = path.join(projectTempDir, TOOL_OUTPUTS_DIR);
if (sessionId) {
const safeSessionId = sanitizeFilenamePart(sessionId);
toolOutputDir = path.join(toolOutputDir, `session-${safeSessionId}`);
}
const outputFile = path.join(toolOutputDir, fileName);
await fsPromises.mkdir(toolOutputDir, { recursive: true });
try {
// Attempt rename (efficient if on the same filesystem)
await fsPromises.rename(sourcePath, outputFile);
} catch (error: unknown) {
// If rename fails (e.g. cross-filesystem), copy and then delete
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
if ((error as { code?: string }).code === 'EXDEV') {
await fsPromises.copyFile(sourcePath, outputFile);
await fsPromises.unlink(sourcePath);
} else {
throw error;
}
}
return { outputFile };
}
/**
* Saves tool output to a temporary file for later retrieval.
*/
@@ -22,12 +22,13 @@ function createFunctionResponsePart(
callId: string,
toolName: string,
output: string,
outputFile?: string,
): Part {
return {
functionResponse: {
id: callId,
name: toolName,
response: { output },
response: { output, outputFile },
},
};
}
@@ -50,9 +51,12 @@ export function convertToFunctionResponse(
llmContent: PartListUnion,
model: string,
config?: Config,
outputFile?: string,
): Part[] {
if (typeof llmContent === 'string') {
return [createFunctionResponsePart(callId, toolName, llmContent)];
return [
createFunctionResponsePart(callId, toolName, llmContent, outputFile),
];
}
const parts = toParts(llmContent);
@@ -94,7 +98,10 @@ export function convertToFunctionResponse(
functionResponse: {
id: callId,
name: toolName,
response: textParts.length > 0 ? { output: textParts.join('\n') } : {},
response: {
...(textParts.length > 0 ? { output: textParts.join('\n') } : {}),
outputFile,
},
},
};
@@ -250,6 +250,12 @@ export function serializeTerminalToObject(
return result;
}
export function serializeAnsiOutputToText(output: AnsiOutput): string {
return output
.map((line) => line.map((segment) => segment.text).join(''))
.join('\n');
}
// ANSI color palette from https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit
const ANSI_COLORS = [
'#000000',
+3 -1
View File
@@ -147,7 +147,9 @@ export class PerfTestHarness {
throw new Error(`No active timer found for label "${label}"`);
}
const wallClockMs = performance.now() - timer.startTime;
// Round wall-clock time to nearest 0.1 ms
const wallClockMs =
Math.round((performance.now() - timer.startTime) * 10) / 10;
const cpuDelta = process.cpuUsage(timer.startCpuUsage);
this.activeTimers.delete(label);
+28 -6
View File
@@ -193,6 +193,28 @@ export function checkModelOutputContent(
return isValid;
}
export interface MetricDataPoint {
attributes?: Record<string, unknown>;
value?: {
sum?: number;
min?: number;
max?: number;
count?: number;
};
startTime?: [number, number];
endTime?: string;
}
export interface TelemetryMetric {
descriptor: {
name: string;
type?: string;
description?: string;
unit?: string;
};
dataPoints: MetricDataPoint[];
}
export interface ParsedLog {
attributes?: {
'event.name'?: string;
@@ -213,11 +235,7 @@ export interface ParsedLog {
prompt_id?: string;
};
scopeMetrics?: {
metrics: {
descriptor: {
name: string;
};
}[];
metrics: TelemetryMetric[];
}[];
}
@@ -1297,6 +1315,10 @@ export class TestRig {
return logs;
}
readTelemetryLogs(): ParsedLog[] {
return this._readAndParseTelemetryLog();
}
private _readAndParseTelemetryLog(): ParsedLog[] {
// Telemetry is always written to the test directory
const logFilePath = join(this.homeDir!, 'telemetry.log');
@@ -1450,7 +1472,7 @@ export class TestRig {
);
}
readMetric(metricName: string): Record<string, unknown> | null {
readMetric(metricName: string): TelemetryMetric | null {
const logs = this._readAndParseTelemetryLog();
for (const logData of logs) {
if (logData.scopeMetrics) {
+29 -4
View File
@@ -1,14 +1,14 @@
{
"version": 1,
"updatedAt": "2026-04-09T02:30:22.000Z",
"updatedAt": "2026-04-14T14:04:02.662Z",
"scenarios": {
"cold-startup-time": {
"wallClockMs": 927.553249999999,
"wallClockMs": 927.6,
"cpuTotalUs": 1470,
"timestamp": "2026-04-08T22:27:54.871Z"
},
"idle-cpu-usage": {
"wallClockMs": 5000.460750000002,
"wallClockMs": 5000.5,
"cpuTotalUs": 12157,
"timestamp": "2026-04-08T22:28:19.098Z"
},
@@ -18,7 +18,7 @@
"timestamp": "2026-04-14T15:22:56.133Z"
},
"skill-loading-time": {
"wallClockMs": 930.0920409999962,
"wallClockMs": 930.1,
"cpuTotalUs": 1323,
"timestamp": "2026-04-08T22:28:23.290Z"
},
@@ -26,6 +26,31 @@
"wallClockMs": 1119.9,
"cpuTotalUs": 2100,
"timestamp": "2026-04-09T02:30:22.000Z"
},
"long-conversation-resume": {
"wallClockMs": 4212.5,
"cpuTotalUs": 351393,
"timestamp": "2026-04-14T14:02:53.268Z"
},
"long-conversation-typing": {
"wallClockMs": 113.7,
"cpuTotalUs": 3304,
"timestamp": "2026-04-14T14:03:12.525Z"
},
"long-conversation-execution": {
"wallClockMs": 248.7,
"cpuTotalUs": 3825,
"timestamp": "2026-04-14T14:03:28.575Z"
},
"long-conversation-terminal-scrolling": {
"wallClockMs": 362.4,
"cpuTotalUs": 12755860,
"timestamp": "2026-04-14T14:03:45.687Z"
},
"long-conversation-alternate-scrolling": {
"wallClockMs": 362.4,
"cpuTotalUs": 12755860,
"timestamp": "2026-04-14T14:04:02.662Z"
}
}
}
+370 -9
View File
@@ -5,10 +5,20 @@
*/
import { describe, it, beforeAll, afterAll } from 'vitest';
import { TestRig, PerfTestHarness } from '@google/gemini-cli-test-utils';
import {
TestRig,
PerfTestHarness,
type PerfSnapshot,
} from '@google/gemini-cli-test-utils';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, readFileSync } from 'node:fs';
import {
existsSync,
readFileSync,
mkdirSync,
copyFileSync,
writeFileSync,
} from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BASELINES_PATH = join(__dirname, 'baselines.json');
@@ -195,7 +205,7 @@ describe('CPU Performance Tests', () => {
const snapshot = await harness.measureWithEventLoop(
'high-volume-output',
async () => {
const runResult = await rig.run({
await rig.run({
args: ['Generate 1M lines of output'],
timeout: 120000,
env: {
@@ -206,7 +216,6 @@ describe('CPU Performance Tests', () => {
DEBUG: 'true',
},
});
console.log(` Child Process Output:`, runResult);
},
);
@@ -246,8 +255,7 @@ describe('CPU Performance Tests', () => {
JSON.stringify(toolLatencyMetric),
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const logs = (rig as any)._readAndParseTelemetryLog();
const logs = rig.readTelemetryLogs();
console.log(` Total telemetry log entries: ${logs.length}`);
for (const logData of logs) {
if (logData.scopeMetrics) {
@@ -272,10 +280,9 @@ describe('CPU Performance Tests', () => {
const findValue = (percentile: string) => {
const dp = eventLoopMetric.dataPoints.find(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(p: any) => p.attributes.percentile === percentile,
(p) => p.attributes?.['percentile'] === percentile,
);
return dp ? dp.value.min : undefined;
return dp?.value?.min;
};
snapshot.childEventLoopDelayP50Ms = findValue('p50');
@@ -296,4 +303,358 @@ describe('CPU Performance Tests', () => {
harness.assertWithinBaseline(result);
}
});
describe('long-conversation', () => {
let rig: TestRig;
const identifier = 'perf-long-conversation';
const SESSION_ID =
'anonymous_unique_id_577296e0eee5afecdcec05d11838e0cd1a851cd97a28119a4a876b11';
const LARGE_CHAT_SOURCE = join(
__dirname,
'..',
'memory-tests',
'large-chat-session.json',
);
beforeAll(async () => {
if (!existsSync(LARGE_CHAT_SOURCE)) {
throw new Error(
`Performance test fixture missing: ${LARGE_CHAT_SOURCE}.`,
);
}
rig = new TestRig();
rig.setup(identifier, {
fakeResponsesPath: join(__dirname, 'perf.long-chat.responses'),
});
const geminiDir = join(rig.homeDir!, '.gemini');
const projectTempDir = join(geminiDir, 'tmp', identifier);
const targetChatsDir = join(projectTempDir, 'chats');
mkdirSync(targetChatsDir, { recursive: true });
writeFileSync(
join(geminiDir, 'projects.json'),
JSON.stringify({
projects: { [rig.testDir!]: identifier },
}),
);
writeFileSync(join(projectTempDir, '.project_root'), rig.testDir!);
copyFileSync(
LARGE_CHAT_SOURCE,
join(targetChatsDir, `session-${SESSION_ID}.json`),
);
});
afterAll(async () => {
await rig.cleanup();
});
it('session-load: resume a 60MB chat history', async () => {
const result = await harness.runScenario(
'long-conversation-resume',
async () => {
const snapshot = await harness.measureWithEventLoop(
'resume',
async () => {
const run = await rig.runInteractive({
args: ['--resume', 'latest'],
env: {
GEMINI_API_KEY: 'fake-perf-test-key',
GEMINI_TELEMETRY_ENABLED: 'true',
GEMINI_MEMORY_MONITOR_INTERVAL: '500',
GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true',
DEBUG: 'true',
},
});
await run.kill();
},
);
return snapshot;
},
);
if (UPDATE_BASELINES) {
harness.updateScenarioBaseline(result);
} else {
harness.assertWithinBaseline(result);
}
});
it('typing: latency when typing into a large session', async () => {
const result = await harness.runScenario(
'long-conversation-typing',
async () => {
const run = await rig.runInteractive({
args: ['--resume', 'latest'],
env: {
GEMINI_API_KEY: 'fake-perf-test-key',
GEMINI_TELEMETRY_ENABLED: 'true',
GEMINI_MEMORY_MONITOR_INTERVAL: '500',
GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true',
DEBUG: 'true',
},
});
const snapshot = await harness.measureWithEventLoop(
'typing',
async () => {
// On average, the expected latency per key is under 30ms.
for (const char of 'Hello') {
await run.type(char);
}
},
);
await run.kill();
return snapshot;
},
);
if (UPDATE_BASELINES) {
harness.updateScenarioBaseline(result);
} else {
harness.assertWithinBaseline(result);
}
});
it('execution: response latency for a simple shell command', async () => {
const result = await harness.runScenario(
'long-conversation-execution',
async () => {
const run = await rig.runInteractive({
args: ['--resume', 'latest'],
env: {
GEMINI_API_KEY: 'fake-perf-test-key',
GEMINI_TELEMETRY_ENABLED: 'true',
GEMINI_MEMORY_MONITOR_INTERVAL: '500',
GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true',
DEBUG: 'true',
},
});
await run.expectText('Type your message');
const snapshot = await harness.measureWithEventLoop(
'execution',
async () => {
await run.sendKeys('!echo hi\r');
await run.expectText('hi');
},
);
await run.kill();
return snapshot;
},
);
if (UPDATE_BASELINES) {
harness.updateScenarioBaseline(result);
} else {
harness.assertWithinBaseline(result);
}
});
it('terminal-scrolling: latency when scrolling a large terminal buffer', async () => {
const result = await harness.runScenario(
'long-conversation-terminal-scrolling',
async () => {
// Enable terminalBuffer to intentionally test CLI scrolling logic
const settingsPath = join(rig.homeDir!, '.gemini', 'settings.json');
writeFileSync(
settingsPath,
JSON.stringify({
security: { folderTrust: { enabled: false } },
ui: { terminalBuffer: true },
}),
);
const run = await rig.runInteractive({
args: ['--resume', 'latest'],
env: {
GEMINI_API_KEY: 'fake-perf-test-key',
GEMINI_TELEMETRY_ENABLED: 'true',
GEMINI_MEMORY_MONITOR_INTERVAL: '500',
GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true',
DEBUG: 'true',
},
});
await run.expectText('Type your message');
for (let i = 0; i < 5; i++) {
await run.sendKeys('\u001b[5~'); // PageUp
}
// Scroll to the very top
await run.sendKeys('\u001b[H'); // Home
// Verify top line of chat is visible.
await run.expectText('Authenticated with');
for (let i = 0; i < 5; i++) {
await run.sendKeys('\u001b[6~'); // PageDown
}
await rig.waitForTelemetryReady();
await run.kill();
const eventLoopMetric = rig.readMetric('event_loop.delay');
const cpuMetric = rig.readMetric('cpu.usage');
let p50Ms = 0;
let p95Ms = 0;
let maxMs = 0;
if (eventLoopMetric) {
const dataPoints = eventLoopMetric.dataPoints;
const p50Data = dataPoints.find(
(dp) => dp.attributes?.['percentile'] === 'p50',
);
const p95Data = dataPoints.find(
(dp) => dp.attributes?.['percentile'] === 'p95',
);
const maxData = dataPoints.find(
(dp) => dp.attributes?.['percentile'] === 'max',
);
if (p50Data?.value?.sum) p50Ms = p50Data.value.sum;
if (p95Data?.value?.sum) p95Ms = p95Data.value.sum;
if (maxData?.value?.sum) maxMs = maxData.value.sum;
}
let cpuTotalUs = 0;
if (cpuMetric) {
const dataPoints = cpuMetric.dataPoints;
for (const dp of dataPoints) {
if (dp.value?.sum && dp.value.sum > 0) {
cpuTotalUs += dp.value.sum;
}
}
}
const cpuUserUs = cpuTotalUs;
const cpuSystemUs = 0;
const snapshot: PerfSnapshot = {
timestamp: Date.now(),
label: 'scrolling',
wallClockMs: Math.round(p50Ms * 10) / 10,
cpuTotalUs,
cpuUserUs,
cpuSystemUs,
eventLoopDelayP50Ms: p50Ms,
eventLoopDelayP95Ms: p95Ms,
eventLoopDelayMaxMs: maxMs,
};
return snapshot;
},
);
if (UPDATE_BASELINES) {
harness.updateScenarioBaseline(result);
} else {
harness.assertWithinBaseline(result);
}
});
it('alternate-scrolling: latency when scrolling a large alternate buffer', async () => {
const result = await harness.runScenario(
'long-conversation-alternate-scrolling',
async () => {
// Enable useAlternateBuffer to intentionally test CLI scrolling logic
const settingsPath = join(rig.homeDir!, '.gemini', 'settings.json');
writeFileSync(
settingsPath,
JSON.stringify({
security: { folderTrust: { enabled: false } },
ui: { useAlternateBuffer: true },
}),
);
const run = await rig.runInteractive({
args: ['--resume', 'latest'],
env: {
GEMINI_API_KEY: 'fake-perf-test-key',
GEMINI_TELEMETRY_ENABLED: 'true',
GEMINI_MEMORY_MONITOR_INTERVAL: '500',
GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true',
DEBUG: 'true',
},
});
await run.expectText('Type your message');
for (let i = 0; i < 5; i++) {
await run.sendKeys('\u001b[5~'); // PageUp
}
// Scroll to the very top
await run.sendKeys('\u001b[H'); // Home
// Verify top line of chat is visible.
await run.expectText('Authenticated with');
for (let i = 0; i < 5; i++) {
await run.sendKeys('\u001b[6~'); // PageDown
}
await rig.waitForTelemetryReady();
await run.kill();
const eventLoopMetric = rig.readMetric('event_loop.delay');
const cpuMetric = rig.readMetric('cpu.usage');
let p50Ms = 0;
let p95Ms = 0;
let maxMs = 0;
if (eventLoopMetric) {
const dataPoints = eventLoopMetric.dataPoints;
const p50Data = dataPoints.find(
(dp) => dp.attributes?.['percentile'] === 'p50',
);
const p95Data = dataPoints.find(
(dp) => dp.attributes?.['percentile'] === 'p95',
);
const maxData = dataPoints.find(
(dp) => dp.attributes?.['percentile'] === 'max',
);
if (p50Data?.value?.sum) p50Ms = p50Data.value.sum;
if (p95Data?.value?.sum) p95Ms = p95Data.value.sum;
if (maxData?.value?.sum) maxMs = maxData.value.sum;
}
let cpuTotalUs = 0;
if (cpuMetric) {
const dataPoints = cpuMetric.dataPoints;
for (const dp of dataPoints) {
if (dp.value?.sum && dp.value.sum > 0) {
cpuTotalUs += dp.value.sum;
}
}
}
const cpuUserUs = cpuTotalUs;
const cpuSystemUs = 0;
const snapshot: PerfSnapshot = {
timestamp: Date.now(),
label: 'scrolling',
wallClockMs: Math.round(p50Ms * 10) / 10,
cpuTotalUs,
cpuUserUs,
cpuSystemUs,
eventLoopDelayP50Ms: p50Ms,
eventLoopDelayP95Ms: p95Ms,
eventLoopDelayMaxMs: maxMs,
};
return snapshot;
},
);
if (UPDATE_BASELINES) {
harness.updateScenarioBaseline(result);
} else {
harness.assertWithinBaseline(result);
}
});
});
});
+4
View File
@@ -0,0 +1,4 @@
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"{\"complexity_reasoning\":\"simple\",\"complexity_score\":1}"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I am a large conversation model response."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"candidatesTokenCount":10,"promptTokenCount":20,"totalTokenCount":30}}]}
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"{\"originalSummary\":\"large chat summary\",\"events\":[]}"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"countTokens","response":{"totalTokens":100}}