diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bc2cf03ce..4a2bf9b660 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,12 @@ jobs: - name: 'Run yamllint' run: 'node scripts/lint.js --yamllint' + - name: 'Build project for typecheck' + run: 'npm run build' + + - name: 'Run typecheck' + run: 'npm run typecheck' + - name: 'Run Prettier' run: 'node scripts/lint.js --prettier' diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md index d9713c973a..45b48ab53d 100644 --- a/docs/changelogs/index.md +++ b/docs/changelogs/index.md @@ -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 diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md index bccbc4bd77..f5d87b8c9e 100644 --- a/docs/changelogs/latest.md +++ b/docs/changelogs/latest.md @@ -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 diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index cf43e62c45..737b0917b4 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: v0.38.0-preview.0 +# Preview release: v0.39.0-preview.0 -Released: April 08, 2026 +Released: April 14, 2026 Our preview release includes the latest, new, and experimental features. This release may not be as stable as our [latest weekly release](latest.md). @@ -13,256 +13,245 @@ npm install -g @google/gemini-cli@preview ## Highlights -- **Context Management:** Introduced a Context Compression Service to optimize - context window usage and landed a background memory service for skill - extraction. -- **Enhanced Security:** Implemented context-aware persistent policy approvals - for smarter tool permissions and enabled `web_fetch` in plan mode with user - confirmation. -- **Workflow Monitoring:** Added background process monitoring and inspection - tools for better visibility into long-running tasks. -- **UI/UX Refinements:** Enhanced the tool confirmation UI, selection layout, - and added support for selective topic expansion and click-to-expand. -- **Core Stability:** Improved sandbox reliability on Linux and Windows, - resolved shebang compatibility issues, and fixed various crashes in the CLI - and core services. +- **Refactored Subagents and Unified Tooling:** Consolidate subagent tools into + a single `invoke_subagent` tool, removed legacy wrapping tools, and improved + turn limits for codebase investigator. +- **Advanced Memory and Skill Management:** Introduced `/memory` inbox for + reviewing extracted skills and added skill patching support, enhancing agent + learning and persistence. +- **Expanded Test and Evaluation Infrastructure:** Added memory and CPU + performance integration test harnesses and generalized evaluation + infrastructure for better suite organization. +- **Sandbox and Security Hardening:** Centralized sandbox paths for Linux and + macOS, enforced read-only security for async git worktree resolution, and + optimized Windows sandbox initialization. +- **Enhanced CLI UX and UI Stability:** Improved scroll momentum, added a + `debugRainbow` setting, and resolved various memory leaks and PTY exhaustion + issues for a smoother terminal experience. ## What's Changed -- 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 - [#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 - [#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 +- refactor(plan): simplify policy priorities and consolidate read-only rules by + @ruomengz in [#24849](https://github.com/google-gemini/gemini-cli/pull/24849) +- feat(test-utils): add memory usage integration test harness by @sripasg in + [#24876](https://github.com/google-gemini/gemini-cli/pull/24876) +- feat(memory): add /memory inbox command for reviewing extracted skills 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 + [#24544](https://github.com/google-gemini/gemini-cli/pull/24544) +- chore(release): bump version to 0.39.0-nightly.20260408.e77b22e63 by + @gemini-cli-robot in + [#24939](https://github.com/google-gemini/gemini-cli/pull/24939) +- fix(core): ensure robust sandbox cleanup in all process execution paths by + @ehedlund in [#24763](https://github.com/google-gemini/gemini-cli/pull/24763) +- chore: update ink version to 6.6.8 by @jacob314 in + [#24934](https://github.com/google-gemini/gemini-cli/pull/24934) +- Changelog for v0.38.0-preview.0 by @gemini-cli-robot in + [#24938](https://github.com/google-gemini/gemini-cli/pull/24938) +- chore: ignore conductor directory by @JayadityaGit in + [#22128](https://github.com/google-gemini/gemini-cli/pull/22128) +- Changelog for v0.37.0 by @gemini-cli-robot in + [#24940](https://github.com/google-gemini/gemini-cli/pull/24940) +- feat(plan): require user confirmation for activate_skill in Plan Mode by + @ruomengz in [#24946](https://github.com/google-gemini/gemini-cli/pull/24946) +- feat(test-utils): add CPU performance integration test harness by @sripasg in + [#24951](https://github.com/google-gemini/gemini-cli/pull/24951) +- fix(cli-ui): enable Ctrl+Backspace for word deletion in Windows Terminal by + @dogukanozen in + [#21447](https://github.com/google-gemini/gemini-cli/pull/21447) +- test(sdk): add unit tests for GeminiCliSession by @AdamyaSingh7 in + [#21897](https://github.com/google-gemini/gemini-cli/pull/21897) +- fix(core): resolve windows symlink bypass and stabilize sandbox integration + tests by @ehedlund in + [#24834](https://github.com/google-gemini/gemini-cli/pull/24834) +- fix(cli): restore file path display in edit and write tool confirmations by + @jwhelangoog in + [#24974](https://github.com/google-gemini/gemini-cli/pull/24974) +- feat(core): refine shell tool description display logic by @jwhelangoog in + [#24903](https://github.com/google-gemini/gemini-cli/pull/24903) +- fix(core): dynamic session ID injection to resolve resume bugs by @scidomino + in [#24972](https://github.com/google-gemini/gemini-cli/pull/24972) +- Update ink version to 6.6.9 by @jacob314 in + [#24980](https://github.com/google-gemini/gemini-cli/pull/24980) +- Generalize evals infra to support more types of evals, organization and + queuing of named suites by @gundermanc in + [#24941](https://github.com/google-gemini/gemini-cli/pull/24941) +- fix(cli): optimize startup with lightweight parent process by @sehoon38 in + [#24667](https://github.com/google-gemini/gemini-cli/pull/24667) +- refactor(sandbox): use centralized sandbox paths in macOS Seatbelt + implementation by @ehedlund in + [#24984](https://github.com/google-gemini/gemini-cli/pull/24984) +- feat(cli): refine tool output formatting for compact mode by @jwhelangoog in + [#24677](https://github.com/google-gemini/gemini-cli/pull/24677) +- fix(sdk): skip broken sendStream tests to unblock nightly by @SandyTao520 in + [#25000](https://github.com/google-gemini/gemini-cli/pull/25000) +- refactor(core): use centralized path resolution for Linux sandbox by @ehedlund + in [#24985](https://github.com/google-gemini/gemini-cli/pull/24985) +- Support ctrl+shift+g by @jacob314 in + [#25035](https://github.com/google-gemini/gemini-cli/pull/25035) +- feat(core): refactor subagent tool to unified invoke_subagent tool by + @abhipatel12 in + [#24489](https://github.com/google-gemini/gemini-cli/pull/24489) +- fix(core): add explicit git identity env vars to prevent sandbox checkpointing + error by @mrpmohiburrahman in + [#19775](https://github.com/google-gemini/gemini-cli/pull/19775) +- fix: respect hideContextPercentage when FooterConfigDialog is closed without + changes by @chernistry in + [#24773](https://github.com/google-gemini/gemini-cli/pull/24773) +- fix(cli): suppress unhandled AbortError logs during request cancellation by + @euxaristia in + [#22621](https://github.com/google-gemini/gemini-cli/pull/22621) +- Automated documentation audit by @g-samroberts in + [#24567](https://github.com/google-gemini/gemini-cli/pull/24567) +- feat(cli): implement useAgentStream hook by @mbleigh in + [#24292](https://github.com/google-gemini/gemini-cli/pull/24292) +- refactor(plan) Clean default plan toml by @ruomengz in + [#25037](https://github.com/google-gemini/gemini-cli/pull/25037) +- refactor(core): remove legacy subagent wrapping tools by @abhipatel12 in + [#25053](https://github.com/google-gemini/gemini-cli/pull/25053) +- fix(core): honor retryDelay in RetryInfo for 503 errors by @yunaseoul in + [#25057](https://github.com/google-gemini/gemini-cli/pull/25057) +- fix(core): remediate subagent memory leaks using AbortSignal in MessageBus by + @abhipatel12 in + [#25048](https://github.com/google-gemini/gemini-cli/pull/25048) +- feat(cli): wire up useAgentStream in AppContainer by @mbleigh in + [#24297](https://github.com/google-gemini/gemini-cli/pull/24297) +- feat(core): migrate chat recording to JSONL streaming by @spencer426 in + [#23749](https://github.com/google-gemini/gemini-cli/pull/23749) +- fix(core): clear 5-minute timeouts in oauth flow to prevent memory leaks by + @spencer426 in + [#24968](https://github.com/google-gemini/gemini-cli/pull/24968) +- fix(sandbox): centralize async git worktree resolution and enforce read-only + security by @ehedlund in + [#25040](https://github.com/google-gemini/gemini-cli/pull/25040) +- feat(test): add high-volume shell test and refine perf harness by @sripasg in + [#24983](https://github.com/google-gemini/gemini-cli/pull/24983) +- fix(core): silently handle EPERM when listing dir structure by @scidomino in + [#25066](https://github.com/google-gemini/gemini-cli/pull/25066) +- Changelog for v0.37.1 by @gemini-cli-robot in + [#25055](https://github.com/google-gemini/gemini-cli/pull/25055) +- fix: decode Uint8Array and multi-byte UTF-8 in API error messages by + @kimjune01 in [#23341](https://github.com/google-gemini/gemini-cli/pull/23341) +- Automated documentation audit results by @g-samroberts in + [#22755](https://github.com/google-gemini/gemini-cli/pull/22755) +- debugging(ui): add optional debugRainbow setting by @jacob314 in + [#25088](https://github.com/google-gemini/gemini-cli/pull/25088) +- fix: resolve lifecycle memory leaks by cleaning up listeners and root closures 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 + [#25049](https://github.com/google-gemini/gemini-cli/pull/25049) +- docs(cli): updates f12 description to be more precise by @JayadityaGit in + [#15816](https://github.com/google-gemini/gemini-cli/pull/15816) +- fix(cli): mark /settings as unsafe to run concurrently by @jacob314 in + [#25061](https://github.com/google-gemini/gemini-cli/pull/25061) +- fix(core): remove buffer slice to prevent OOM on large output streams by + @spencer426 in + [#25094](https://github.com/google-gemini/gemini-cli/pull/25094) +- feat(core): persist subagent agentId in tool call records by @abhipatel12 in + [#25092](https://github.com/google-gemini/gemini-cli/pull/25092) +- chore(core): increase codebase investigator turn limits to 50 by @abhipatel12 + in [#25125](https://github.com/google-gemini/gemini-cli/pull/25125) +- refactor(core): consolidate execute() arguments into ExecuteOptions by + @mbleigh in [#25101](https://github.com/google-gemini/gemini-cli/pull/25101) +- feat(core): add Strategic Re-evaluation guidance to system prompt by + @aishaneeshah in + [#25062](https://github.com/google-gemini/gemini-cli/pull/25062) +- fix(core): preserve shell execution config fields on update by + @jasonmatthewsuhari in + [#25113](https://github.com/google-gemini/gemini-cli/pull/25113) +- docs: add vi shortcuts and clarify MCP sandbox setup by @chrisjcthomas in + [#21679](https://github.com/google-gemini/gemini-cli/pull/21679) +- fix(cli): pass session id to interactive shell executions by + @jasonmatthewsuhari in + [#25114](https://github.com/google-gemini/gemini-cli/pull/25114) +- fix(cli): resolve text sanitization data loss due to C1 control characters by + @euxaristia in + [#22624](https://github.com/google-gemini/gemini-cli/pull/22624) +- feat(core): add large memory regression test by @cynthialong0-0 in + [#25059](https://github.com/google-gemini/gemini-cli/pull/25059) +- fix(core): resolve PTY exhaustion and orphan MCP subprocess leaks by + @spencer426 in + [#25079](https://github.com/google-gemini/gemini-cli/pull/25079) +- chore(deps): update vulnerable dependencies via npm audit fix by @scidomino in + [#25140](https://github.com/google-gemini/gemini-cli/pull/25140) +- perf(sandbox): optimize Windows sandbox initialization via native ACL + application by @ehedlund in + [#25077](https://github.com/google-gemini/gemini-cli/pull/25077) +- chore: switch from keytar to @github/keytar by @cocosheng-g in + [#25143](https://github.com/google-gemini/gemini-cli/pull/25143) +- fix: improve audio MIME normalization and validation in file reads by + @junaiddshaukat in + [#21636](https://github.com/google-gemini/gemini-cli/pull/21636) +- docs: Update docs-audit to include changes in PR body by @g-samroberts in + [#25153](https://github.com/google-gemini/gemini-cli/pull/25153) +- docs: correct documentation for enforced authentication type by @cocosheng-g + in [#25142](https://github.com/google-gemini/gemini-cli/pull/25142) +- fix(cli): exclude update_topic from confirmation queue count by @Abhijit-2592 + in [#24945](https://github.com/google-gemini/gemini-cli/pull/24945) +- Memory fix for trace's streamWrapper. by @anthraxmilkshake in + [#25089](https://github.com/google-gemini/gemini-cli/pull/25089) +- fix(core): fix quota footer for non-auto models and improve display by + @jackwotherspoon in + [#25121](https://github.com/google-gemini/gemini-cli/pull/25121) +- docs(contributing): clarify self-assignment policy for issues by @jmr in + [#23087](https://github.com/google-gemini/gemini-cli/pull/23087) +- feat(core): add skill patching support with /memory inbox integration by + @SandyTao520 in + [#25148](https://github.com/google-gemini/gemini-cli/pull/25148) +- Stop suppressing thoughts and text in model response by @gundermanc in + [#25073](https://github.com/google-gemini/gemini-cli/pull/25073) +- fix(release): prefix git hash in nightly versions to prevent semver + normalization by @SandyTao520 in + [#25304](https://github.com/google-gemini/gemini-cli/pull/25304) +- feat(cli): extract QuotaContext and resolve infinite render loop by @Adib234 + in [#24959](https://github.com/google-gemini/gemini-cli/pull/24959) +- refactor(core): extract and centralize sandbox path utilities by @ehedlund in + [#25305](https://github.com/google-gemini/gemini-cli/pull/25305) +- feat(ui): added enhancements to scroll momentum by @devr0306 in + [#24447](https://github.com/google-gemini/gemini-cli/pull/24447) +- fix(core): replace custom binary detection with isbinaryfile to correctly + handle UTF-8 (U+FFFD) by @Anjaligarhwal in + [#25297](https://github.com/google-gemini/gemini-cli/pull/25297) +- feat(agent): implement tool-controlled display protocol (Steps 2-3) by + @mbleigh in [#25134](https://github.com/google-gemini/gemini-cli/pull/25134) +- Stop showing scrollbar unless we are in terminalBuffer mode by @jacob314 in + [#25320](https://github.com/google-gemini/gemini-cli/pull/25320) +- feat: support auth block in MCP servers config in agents by @TanmayVartak in + [#24770](https://github.com/google-gemini/gemini-cli/pull/24770) +- fix(core): expose GEMINI_PLANS_DIR to hook environment by @Adib234 in + [#25296](https://github.com/google-gemini/gemini-cli/pull/25296) +- feat(core): implement silent fallback for Plan Mode model routing by @jerop in + [#25317](https://github.com/google-gemini/gemini-cli/pull/25317) +- fix: correct redirect count increment in fetchJson by @KevinZhao in + [#24896](https://github.com/google-gemini/gemini-cli/pull/24896) +- fix(core): prevent secondary crash in ModelRouterService finally block 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) + [#25333](https://github.com/google-gemini/gemini-cli/pull/25333) +- feat(core): introduce decoupled ContextManager and Sidecar architecture by + @joshualitt in + [#24752](https://github.com/google-gemini/gemini-cli/pull/24752) +- docs(core): update generalist agent documentation by @abhipatel12 in + [#25325](https://github.com/google-gemini/gemini-cli/pull/25325) +- chore(mcp): check MCP error code over brittle string match by @jackwotherspoon + in [#25381](https://github.com/google-gemini/gemini-cli/pull/25381) +- feat(plan): update plan mode prompt to allow showing plan content by @ruomengz + in [#25058](https://github.com/google-gemini/gemini-cli/pull/25058) +- test(core): improve sandbox integration test coverage and fix OS-specific + failures by @ehedlund in + [#25307](https://github.com/google-gemini/gemini-cli/pull/25307) +- fix(core): use debug level for keychain fallback logging by @ehedlund in + [#25398](https://github.com/google-gemini/gemini-cli/pull/25398) +- feat(test): add a performance test in asian language by @cynthialong0-0 in + [#25392](https://github.com/google-gemini/gemini-cli/pull/25392) +- feat(cli): enable mouse clicking for cursor positioning in AskUser multi-line + answers by @Adib234 in + [#24630](https://github.com/google-gemini/gemini-cli/pull/24630) +- fix(core): detect kmscon terminal as supporting true color by @claygeo in + [#25282](https://github.com/google-gemini/gemini-cli/pull/25282) +- ci: add agent session drift check workflow by @adamfweidman in + [#25389](https://github.com/google-gemini/gemini-cli/pull/25389) +- use macos-latest-large runner where applicable. by @scidomino in + [#25413](https://github.com/google-gemini/gemini-cli/pull/25413) +- Changelog for v0.37.2 by @gemini-cli-robot in + [#25336](https://github.com/google-gemini/gemini-cli/pull/25336) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.37.0-preview.2...v0.38.0-preview.0 +https://github.com/google-gemini/gemini-cli/compare/v0.38.0-preview.0...v0.39.0-preview.0 diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 00677943ad..8a6d0b5370 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -130,7 +130,9 @@ These are the only allowed tools: [`cli_help`](../core/subagents.md#cli-help-agent) - **Interaction:** [`ask_user`](../tools/ask-user.md) - **MCP tools (Read):** Read-only [MCP tools](../tools/mcp-server.md) (for - example, `github_read_issue`, `postgres_read_schema`) are allowed. + example, `github_read_issue`, `postgres_read_schema`) and core + [MCP resource tools](../tools/mcp-resources.md) (`list_mcp_resources`, + `read_mcp_resource`) are allowed. - **Planning (Write):** [`write_file`](../tools/file-system.md#3-write_file-writefile) and [`replace`](../tools/file-system.md#6-replace-edit) only allowed for `.md` diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 88a5d2ff83..6717bcce9a 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -24,20 +24,21 @@ they appear in the UI. ### General -| UI Label | Setting | Description | Default | -| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` | -| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). | `"default"` | -| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | -| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. | `false` | -| Enable Plan Mode | `general.plan.enabled` | Enable Plan Mode for read-only safety during planning. | `true` | -| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode. | `undefined` | -| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` | -| Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` | -| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` | -| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | -| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `true` | -| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` | +| UI Label | Setting | Description | Default | +| ----------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` | +| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). | `"default"` | +| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | +| Enable Terminal Notifications | `general.enableNotifications` | Enable terminal run-event notifications for action-required prompts and session completion. | `false` | +| Terminal Notification Method | `general.notificationMethod` | How to send terminal notifications. | `"auto"` | +| Enable Plan Mode | `general.plan.enabled` | Enable Plan Mode for read-only safety during planning. | `true` | +| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode. | `undefined` | +| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` | +| Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` | +| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` | +| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | +| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `true` | +| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` | ### Output diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 05368f20fe..40eb9d11ca 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -134,10 +134,15 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **`general.enableNotifications`** (boolean): - - **Description:** Enable run-event notifications for action-required prompts - and session completion. + - **Description:** Enable terminal run-event notifications for action-required + prompts and session completion. - **Default:** `false` +- **`general.notificationMethod`** (enum): + - **Description:** How to send terminal notifications. + - **Default:** `"auto"` + - **Values:** `"auto"`, `"osc9"`, `"osc777"`, `"bell"` + - **`general.checkpointing.enabled`** (boolean): - **Description:** Enable session checkpointing for recovery - **Default:** `false` @@ -2148,6 +2153,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 diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index a86c201b85..d9dc21f49c 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -120,6 +120,12 @@ There are three possible decisions a rule can enforce: ### Priority system and tiers +> [!WARNING] The **Workspace** tier (project-level policies) is currently +> non-functional. Defining policies in a workspace's `.gemini/policies` +> directory will not have any effect. See +> [issue #18186](https://github.com/google-gemini/gemini-cli/issues/18186). Use +> User or Admin policies instead. + The policy engine uses a sophisticated priority system to resolve conflicts when multiple rules match a single tool call. The core principle is simple: **the rule with the highest priority wins**. @@ -127,13 +133,13 @@ rule with the highest priority wins**. To provide a clear hierarchy, policies are organized into three tiers. Each tier has a designated number that forms the base of the final priority calculation. -| Tier | Base | Description | -| :-------- | :--- | :-------------------------------------------------------------------------------- | -| Default | 1 | Built-in policies that ship with Gemini CLI. | -| Extension | 2 | Policies defined in extensions. | -| Workspace | 3 | Policies defined in the current workspace's configuration directory. | -| User | 4 | Custom policies defined by the user. | -| Admin | 5 | Policies managed by an administrator (for example, in an enterprise environment). | +| Tier | Base | Description | +| :-------- | :--- | :-------------------------------------------------------------------------------------------- | +| Default | 1 | Built-in policies that ship with Gemini CLI. | +| Extension | 2 | Policies defined in extensions. | +| Workspace | 3 | **(Currently disabled)** Policies defined in the current workspace's configuration directory. | +| User | 4 | Custom policies defined by the user. | +| Admin | 5 | Policies managed by an administrator (for example, in an enterprise environment). | Within a TOML policy file, you assign a priority value from **0 to 999**. The engine transforms this into a final priority using the following formula: @@ -214,11 +220,11 @@ User, and (if configured) Admin directories. ### Policy locations -| Tier | Type | Location | -| :------------ | :----- | :---------------------------------------- | -| **User** | Custom | `~/.gemini/policies/*.toml` | -| **Workspace** | Custom | `$WORKSPACE_ROOT/.gemini/policies/*.toml` | -| **Admin** | System | _See below (OS specific)_ | +| Tier | Type | Location | +| :------------ | :----- | :------------------------------------------------------- | +| **User** | Custom | `~/.gemini/policies/*.toml` | +| **Workspace** | Custom | **(Disabled)** `$WORKSPACE_ROOT/.gemini/policies/*.toml` | +| **Admin** | System | _See below (OS specific)_ | #### System-wide policies (Admin) diff --git a/docs/reference/tools.md b/docs/reference/tools.md index a33742a7a8..46708e16bc 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -92,6 +92,13 @@ each tool. | [`ask_user`](../tools/ask-user.md) | `Communicate` | Requests clarification or missing information via an interactive dialog. | | [`write_todos`](../tools/todos.md) | `Other` | Maintains an internal list of subtasks. The model uses this to track its own progress. | +### MCP + +| Tool | Kind | Description | +| :------------------------------------------------ | :------- | :--------------------------------------------------------------------- | +| [`list_mcp_resources`](../tools/mcp-resources.md) | `Search` | Lists all available resources exposed by connected MCP servers. | +| [`read_mcp_resource`](../tools/mcp-resources.md) | `Read` | Reads the content of a specific Model Context Protocol (MCP) resource. | + ### Memory | Tool | Kind | Description | diff --git a/docs/sidebar.json b/docs/sidebar.json index ad5741699e..0d94b1ac60 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -122,7 +122,14 @@ } ] }, - { "label": "MCP servers", "slug": "docs/tools/mcp-server" }, + { + "label": "MCP servers", + "collapsed": true, + "items": [ + { "label": "Overview", "slug": "docs/tools/mcp-server" }, + { "label": "Resource tools", "slug": "docs/tools/mcp-resources" } + ] + }, { "label": "Model routing", "slug": "docs/cli/model-routing" }, { "label": "Model selection", "slug": "docs/cli/model" }, { diff --git a/docs/tools/mcp-resources.md b/docs/tools/mcp-resources.md new file mode 100644 index 0000000000..a6dad26e10 --- /dev/null +++ b/docs/tools/mcp-resources.md @@ -0,0 +1,44 @@ +# MCP resource tools + +MCP resource tools let Gemini CLI discover and retrieve data from contextual +resources exposed by Model Context Protocol (MCP) servers. + +## 1. `list_mcp_resources` (ListMcpResources) + +`list_mcp_resources` retrieves a list of all available resources from connected +MCP servers. This is primarily a discovery tool that helps the model understand +what external data sources are available for reference. + +- **Tool name:** `list_mcp_resources` +- **Display name:** List MCP Resources +- **Kind:** `Search` +- **File:** `list-mcp-resources.ts` +- **Parameters:** + - `serverName` (string, optional): An optional filter to list resources from a + specific server. +- **Behavior:** + - Iterates through all connected MCP servers. + - Fetches the list of resources each server exposes. + - Formats the results into a plain-text list of URIs and descriptions. +- **Output (`llmContent`):** A formatted list of available resources, including + their URI, server name, and optional description. +- **Confirmation:** No. This is a read-only discovery tool. + +## 2. `read_mcp_resource` (ReadMcpResource) + +`read_mcp_resource` retrieves the content of a specific resource identified by +its URI. + +- **Tool name:** `read_mcp_resource` +- **Display name:** Read MCP Resource +- **Kind:** `Read` +- **File:** `read-mcp-resource.ts` +- **Parameters:** + - `uri` (string, required): The URI of the MCP resource to read. +- **Behavior:** + - Locates the resource and its associated server by URI. + - Calls the server's `resources/read` method. + - Processes the response, extracting text or binary data. +- **Output (`llmContent`):** The content of the resource. For binary data, it + returns a placeholder indicating the data type. +- **Confirmation:** No. This is a read-only retrieval tool. diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index f74ba1de12..d9d8835c8c 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -64,7 +64,8 @@ Gemini CLI supports three MCP transport types: Some MCP servers expose contextual “resources” in addition to the tools and prompts. Gemini CLI discovers these automatically and gives you the possibility -to reference them in the chat. +to reference them in the chat. For more information on the tools used to +interact with these resources, see [MCP resource tools](mcp-resources.md). ### Discovery and listing diff --git a/evals/background_processes.eval.ts b/evals/background_processes.eval.ts index 039a416ae9..b8a783277f 100644 --- a/evals/background_processes.eval.ts +++ b/evals/background_processes.eval.ts @@ -11,6 +11,8 @@ import path from 'node:path'; describe('Background Process Monitoring', () => { evalTest('USUALLY_PASSES', { + suiteName: 'default', + suiteType: 'behavioral', name: 'should naturally use read output tool to find token', prompt: "Run the script using 'bash generate_token.sh'. It will emit a token after a short delay and continue running. Find the token and tell me what it is.", @@ -50,6 +52,8 @@ sleep 100 }); evalTest('USUALLY_PASSES', { + suiteName: 'default', + suiteType: 'behavioral', name: 'should naturally use list tool to verify multiple processes', prompt: "Start three background processes that run 'sleep 100', 'sleep 200', and 'sleep 300' respectively. Verify that all three are currently running.", diff --git a/evals/cli_help_delegation.eval.ts b/evals/cli_help_delegation.eval.ts index e1714c0636..32d6701917 100644 --- a/evals/cli_help_delegation.eval.ts +++ b/evals/cli_help_delegation.eval.ts @@ -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 }, diff --git a/evals/component-test-helper.ts b/evals/component-test-helper.ts index 9be68e6936..097f6e3d05 100644 --- a/evals/component-test-helper.ts +++ b/evals/component-test-helper.ts @@ -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 }); } } diff --git a/evals/generalist_agent.eval.ts b/evals/generalist_agent.eval.ts index b8313079e9..8c3f3d0632 100644 --- a/evals/generalist_agent.eval.ts +++ b/evals/generalist_agent.eval.ts @@ -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 diff --git a/evals/plan_mode.eval.ts b/evals/plan_mode.eval.ts index d52415a26d..843d45cccc 100644 --- a/evals/plan_mode.eval.ts +++ b/evals/plan_mode.eval.ts @@ -298,6 +298,8 @@ describe('plan_mode', () => { }); evalTest('ALWAYS_PASSES', { + suiteName: 'default', + suiteType: 'behavioral', name: 'should transition from plan mode to normal execution and create a plan file from scratch', params: { settings, @@ -333,7 +335,7 @@ describe('plan_mode', () => { expect( planWrite?.toolRequest.success, - `Expected write_file to succeed, but got error: ${planWrite?.toolRequest.error}`, + `Expected write_file to succeed, but got error: ${(planWrite?.toolRequest as any).error}`, ).toBe(true); assertModelHasOutput(result); @@ -341,6 +343,8 @@ describe('plan_mode', () => { }); evalTest('USUALLY_PASSES', { + suiteName: 'default', + suiteType: 'behavioral', name: 'should not exit plan mode or draft before informal agreement', approvalMode: ApprovalMode.PLAN, params: { diff --git a/evals/save_memory.eval.ts b/evals/save_memory.eval.ts index 5a228ed065..314f052f19 100644 --- a/evals/save_memory.eval.ts +++ b/evals/save_memory.eval.ts @@ -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); }, diff --git a/evals/skill_extraction.eval.ts b/evals/skill_extraction.eval.ts new file mode 100644 index 0000000000..28ca557f01 --- /dev/null +++ b/evals/skill_extraction.eval.ts @@ -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 { + 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 { + 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, + ); + }, + }); +}); diff --git a/evals/subtask_delegation.eval.ts b/evals/subtask_delegation.eval.ts new file mode 100644 index 0000000000..d7e785072f --- /dev/null +++ b/evals/subtask_delegation.eval.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { TRACKER_CREATE_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', { + suiteName: 'default', + suiteType: 'behavioral', + 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', { + suiteName: 'default', + suiteType: 'behavioral', + 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, + ]); + }, + }); +}); diff --git a/evals/tracker.eval.ts b/evals/tracker.eval.ts index 44fbdc46e0..83ffc61d68 100644 --- a/evals/tracker.eval.ts +++ b/evals/tracker.eval.ts @@ -119,6 +119,8 @@ describe('tracker_mode', () => { }); evalTest('USUALLY_PASSES', { + suiteName: 'default', + suiteType: 'behavioral', name: 'should correctly identify the task tracker storage location from the system prompt', params: { settings: { experimental: { taskTracker: true } }, diff --git a/evals/tsconfig.json b/evals/tsconfig.json new file mode 100644 index 0000000000..7d680cc330 --- /dev/null +++ b/evals/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "paths": { + "@google/gemini-cli-core": ["../packages/core/index.ts"], + "@google/gemini-cli": ["../packages/cli/index.ts"] + } + }, + "include": ["**/*.ts"], + "exclude": ["logs"], + "references": [{ "path": "../packages/core" }, { "path": "../packages/cli" }] +} diff --git a/evals/unsafe-cloning.eval.ts b/evals/unsafe-cloning.eval.ts index 7a37a77c1b..69193a55ec 100644 --- a/evals/unsafe-cloning.eval.ts +++ b/evals/unsafe-cloning.eval.ts @@ -7,6 +7,8 @@ import { evalTest, TestRig } from './test-helper.js'; evalTest('USUALLY_PASSES', { + suiteName: 'default', + suiteType: 'behavioral', name: 'Reproduction: Agent uses Object.create() for cloning/delegation', prompt: 'Create a utility function `createScopedConfig(config: Config, additionalDirectories: string[]): Config` in `packages/core/src/config/scoped-config.ts` that returns a new Config instance. This instance should override `getWorkspaceContext()` to include the additional directories, but delegate all other method calls (like `isPathAllowed` or `validatePathAccess`) to the original config. Note that `Config` is a complex class with private state and cannot be easily shallow-copied or reconstructed.', diff --git a/evals/update_topic.eval.ts b/evals/update_topic.eval.ts index 8a6f3f75ac..af7f69b53b 100644 --- a/evals/update_topic.eval.ts +++ b/evals/update_topic.eval.ts @@ -21,6 +21,8 @@ describe('update_topic_behavior', () => { * more than 1/4 turns. */ evalTest('USUALLY_PASSES', { + suiteName: 'default', + suiteType: 'behavioral', name: 'update_topic should be used at start, end and middle for complex tasks', prompt: `Create a simple users REST API using Express. 1. Initialize a new npm project and install express. @@ -117,6 +119,8 @@ describe('update_topic_behavior', () => { }); evalTest('USUALLY_PASSES', { + suiteName: 'default', + suiteType: 'behavioral', name: 'update_topic should NOT be used for informational coding tasks (Obvious)', approvalMode: 'default', prompt: @@ -142,6 +146,8 @@ describe('update_topic_behavior', () => { }); evalTest('USUALLY_PASSES', { + suiteName: 'default', + suiteType: 'behavioral', name: 'update_topic should NOT be used for surgical symbol searches (Grey Area)', approvalMode: 'default', prompt: @@ -169,6 +175,8 @@ describe('update_topic_behavior', () => { }); evalTest('USUALLY_PASSES', { + suiteName: 'default', + suiteType: 'behavioral', name: 'update_topic should be used for medium complexity multi-step tasks', prompt: 'Refactor the `users-api` project. Move the routing logic from src/app.ts into a new file src/routes.ts, and update app.ts to use the new routes file.', @@ -212,7 +220,9 @@ export default app; expect(topicCalls.length).toBeGreaterThanOrEqual(2); // Verify it actually did the refactoring to ensure it didn't just fail immediately - expect(fs.existsSync(path.join(rig.testDir, 'src/routes.ts'))).toBe(true); + expect(fs.existsSync(path.join(rig.testDir!, 'src/routes.ts'))).toBe( + true, + ); }, }); @@ -224,6 +234,8 @@ export default app; * the prompt change that improves the behavior. */ evalTest('USUALLY_PASSES', { + suiteName: 'default', + suiteType: 'behavioral', name: 'update_topic should not be called twice in a row', prompt: ` We need to build a C compiler. diff --git a/integration-tests/concurrency-limit.test.ts b/integration-tests/concurrency-limit.test.ts index ba165b3393..2888c3d2a0 100644 --- a/integration-tests/concurrency-limit.test.ts +++ b/integration-tests/concurrency-limit.test.ts @@ -39,7 +39,11 @@ describe('web-fetch rate limiting', () => { const rateLimitedCalls = toolLogs.filter( (log) => log.toolRequest.name === 'web_fetch' && - log.toolRequest.error?.includes('Rate limit exceeded'), + ( + ('error' in log.toolRequest + ? (log.toolRequest as unknown as Record)['error'] + : '') as string + )?.includes('Rate limit exceeded'), ); expect(rateLimitedCalls.length).toBeGreaterThan(0); diff --git a/integration-tests/hooks-system.test.ts b/integration-tests/hooks-system.test.ts index 73a7ca03ab..3117118de4 100644 --- a/integration-tests/hooks-system.test.ts +++ b/integration-tests/hooks-system.test.ts @@ -164,7 +164,8 @@ describe.skipIf(skipFlaky)( ); expect(blockHook).toBeDefined(); expect( - blockHook?.hookCall.stdout + blockHook?.hookCall.stderr, + (blockHook?.hookCall.stdout || '') + + (blockHook?.hookCall.stderr || ''), ).toContain(blockMsg); }); diff --git a/integration-tests/mcp-list-resources.responses b/integration-tests/mcp-list-resources.responses new file mode 100644 index 0000000000..d3f3e134e9 --- /dev/null +++ b/integration-tests/mcp-list-resources.responses @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_mcp_resources","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Here are the resources: test://resource1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]} diff --git a/integration-tests/mcp-read-resource.responses b/integration-tests/mcp-read-resource.responses new file mode 100644 index 0000000000..9ba9da205a --- /dev/null +++ b/integration-tests/mcp-read-resource.responses @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_mcp_resource","args":{"uri":"test://resource1"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The content is: content of resource 1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]} diff --git a/integration-tests/mcp-resources.responses b/integration-tests/mcp-resources.responses new file mode 100644 index 0000000000..6a3307ddb4 --- /dev/null +++ b/integration-tests/mcp-resources.responses @@ -0,0 +1,4 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_mcp_resources","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Here are the resources: test://resource1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_mcp_resource","args":{"uri":"test://resource1"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The content is: content of resource 1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]} diff --git a/integration-tests/mcp-resources.test.ts b/integration-tests/mcp-resources.test.ts new file mode 100644 index 0000000000..ac04e36e38 --- /dev/null +++ b/integration-tests/mcp-resources.test.ts @@ -0,0 +1,178 @@ +/** + * @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 { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +describe('mcp-resources-integration', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => await rig.cleanup()); + + it('should list mcp resources', async () => { + await rig.setup('mcp-list-resources-test', { + settings: { + model: { + name: 'gemini-3-flash-preview', + }, + }, + fakeResponsesPath: join(__dirname, 'mcp-list-resources.responses'), + }); + + // Workaround for ProjectRegistry save issue + const userGeminiDir = join(rig.homeDir!, '.gemini'); + fs.writeFileSync(join(userGeminiDir, 'projects.json'), '{"projects":{}}'); + + // Add a dummy server to get setup done + rig.addTestMcpServer('resource-server', { + name: 'resource-server', + tools: [], + }); + + // Overwrite the script with resource support + const scriptPath = join(rig.testDir!, 'test-mcp-resource-server.mjs'); + const scriptContent = ` +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + ListResourcesRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +const server = new Server( + { + name: 'resource-server', + version: '1.0.0', + }, + { + capabilities: { + resources: {}, + }, + }, +); + +server.setRequestHandler(ListResourcesRequestSchema, async () => { + return { + resources: [ + { + uri: 'test://resource1', + name: 'Resource 1', + mimeType: 'text/plain', + description: 'A test resource', + } + ], + }; +}); + +const transport = new StdioServerTransport(); +await server.connect(transport); +`; + fs.writeFileSync(scriptPath, scriptContent); + + const output = await rig.run({ + args: 'List all available MCP resources.', + env: { GEMINI_API_KEY: 'dummy' }, + }); + + const foundCall = await rig.waitForToolCall('list_mcp_resources'); + expect(foundCall).toBeTruthy(); + expect(output).toContain('test://resource1'); + }, 60000); + + it('should read mcp resource', async () => { + await rig.setup('mcp-read-resource-test', { + settings: { + model: { + name: 'gemini-3-flash-preview', + }, + }, + fakeResponsesPath: join(__dirname, 'mcp-read-resource.responses'), + }); + + // Workaround for ProjectRegistry save issue + const userGeminiDir = join(rig.homeDir!, '.gemini'); + fs.writeFileSync(join(userGeminiDir, 'projects.json'), '{"projects":{}}'); + + // Add a dummy server to get setup done + rig.addTestMcpServer('resource-server', { + name: 'resource-server', + tools: [], + }); + + // Overwrite the script with resource support + const scriptPath = join(rig.testDir!, 'test-mcp-resource-server.mjs'); + const scriptContent = ` +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + ListResourcesRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +const server = new Server( + { + name: 'resource-server', + version: '1.0.0', + }, + { + capabilities: { + resources: {}, + }, + }, +); + +// Need to provide list resources so the tool is active! +server.setRequestHandler(ListResourcesRequestSchema, async () => { + return { + resources: [ + { + uri: 'test://resource1', + name: 'Resource 1', + mimeType: 'text/plain', + description: 'A test resource', + } + ], + }; +}); + +server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + if (request.params.uri === 'test://resource1') { + return { + contents: [ + { + uri: 'test://resource1', + mimeType: 'text/plain', + text: 'This is the content of resource 1', + } + ], + }; + } + throw new Error('Resource not found'); +}); + +const transport = new StdioServerTransport(); +await server.connect(transport); +`; + fs.writeFileSync(scriptPath, scriptContent); + + const output = await rig.run({ + args: 'Read the MCP resource test://resource1.', + env: { GEMINI_API_KEY: 'dummy' }, + }); + + const foundCall = await rig.waitForToolCall('read_mcp_resource'); + expect(foundCall).toBeTruthy(); + expect(output).toContain('content of resource 1'); + }, 60000); +}); diff --git a/integration-tests/plan-mode.test.ts b/integration-tests/plan-mode.test.ts index 94ed65f1fe..6f90c60fec 100644 --- a/integration-tests/plan-mode.test.ts +++ b/integration-tests/plan-mode.test.ts @@ -108,7 +108,7 @@ describe('Plan Mode', () => { ).toBeDefined(); expect( planWrite?.toolRequest.success, - `Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`, + `Expected write_file to succeed, but it failed with error: ${'error' in (planWrite?.toolRequest || {}) ? (planWrite?.toolRequest as unknown as Record)['error'] : 'unknown'}`, ).toBe(true); }); @@ -221,7 +221,7 @@ describe('Plan Mode', () => { ).toBeDefined(); expect( planWrite?.toolRequest.success, - `Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`, + `Expected write_file to succeed, but it failed with error: ${'error' in (planWrite?.toolRequest || {}) ? (planWrite?.toolRequest as unknown as Record)['error'] : 'unknown'}`, ).toBe(true); }); it('should switch from a pro model to a flash model after exiting plan mode', async () => { @@ -270,13 +270,24 @@ describe('Plan Mode', () => { ); const apiRequests = rig.readAllApiRequest(); - const modelNames = apiRequests.map((r) => r.attributes?.model || 'unknown'); + const modelNames = apiRequests.map( + (r) => + ('model' in (r.attributes || {}) + ? (r.attributes as unknown as Record)['model'] + : 'unknown') || 'unknown', + ); const proRequests = apiRequests.filter((r) => - r.attributes?.model?.includes('pro'), + ('model' in (r.attributes || {}) + ? (r.attributes as unknown as Record)['model'] + : 'unknown' + )?.includes('pro'), ); const flashRequests = apiRequests.filter((r) => - r.attributes?.model?.includes('flash'), + ('model' in (r.attributes || {}) + ? (r.attributes as unknown as Record)['model'] + : 'unknown' + )?.includes('flash'), ); expect( diff --git a/integration-tests/tsconfig.json b/integration-tests/tsconfig.json index 295741e16f..1e813bfbff 100644 --- a/integration-tests/tsconfig.json +++ b/integration-tests/tsconfig.json @@ -5,5 +5,9 @@ "allowJs": true }, "include": ["**/*.ts"], - "references": [{ "path": "../packages/core" }] + "references": [ + { "path": "../packages/core" }, + { "path": "../packages/test-utils" }, + { "path": "../packages/cli" } + ] } diff --git a/memory-tests/memory-usage.test.ts b/memory-tests/memory-usage.test.ts index eb363a0135..5cff2f98ab 100644 --- a/memory-tests/memory-usage.test.ts +++ b/memory-tests/memory-usage.test.ts @@ -489,8 +489,12 @@ async function generateSharedLargeChatData(tempDir: string) { // Wait for streams to finish await Promise.all([ - new Promise((res) => activeResponsesStream.on('finish', res)), - new Promise((res) => resumeResponsesStream.on('finish', res)), + new Promise((res) => + activeResponsesStream.on('finish', () => res(undefined)), + ), + new Promise((res) => + resumeResponsesStream.on('finish', () => res(undefined)), + ), ]); return { diff --git a/package-lock.json b/package-lock.json index 5d225e6ad1..9f492f9d62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@google/gemini-cli", - "version": "0.39.0-nightly.20260408.e77b22e63", + "version": "0.40.0-nightly.20260414.g5b1f7375a", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.39.0-nightly.20260408.e77b22e63", + "version": "0.40.0-nightly.20260414.g5b1f7375a", "workspaces": [ "packages/*" ], @@ -63,10 +63,12 @@ "prettier": "^3.5.3", "react-devtools-core": "^6.1.2", "react-dom": "^19.2.0", + "read-package-up": "^11.0.0", "semver": "^7.7.2", "strip-ansi": "^7.1.2", "ts-prune": "^0.10.3", "tsx": "^4.20.3", + "typescript": "^5.8.3", "typescript-eslint": "^8.30.1", "vitest": "^3.2.4", "yargs": "^17.7.2" @@ -934,9 +936,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -966,9 +968,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -1728,29 +1730,6 @@ "node": ">=8" } }, - "node_modules/@joshua.litt/get-ripgrep": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@joshua.litt/get-ripgrep/-/get-ripgrep-0.0.3.tgz", - "integrity": "sha512-rycdieAKKqXi2bsM7G2ayDiNk5CAX8ZOzsTQsirfOqUKPef04Xw40BWGGyimaOOuvPgLWYt3tPnLLG3TvPXi5Q==", - "license": "MIT", - "dependencies": { - "@lvce-editor/verror": "^1.6.0", - "execa": "^9.5.2", - "extract-zip": "^2.0.1", - "fs-extra": "^11.3.0", - "got": "^14.4.5", - "path-exists": "^5.0.0", - "xdg-basedir": "^5.1.0" - } - }, - "node_modules/@joshua.litt/get-ripgrep/node_modules/path-exists": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1933,12 +1912,6 @@ "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", "license": "MIT" }, - "node_modules/@lvce-editor/verror": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@lvce-editor/verror/-/verror-1.7.0.tgz", - "integrity": "sha512-+LGuAEIC2L7pbvkyAQVWM2Go0dAy+UWEui28g07zNtZsCBhm+gusBK8PNwLJLV5Jay+TyUYuwLIbJdjLLzqEBg==", - "license": "MIT" - }, "node_modules/@lydell/node-pty": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz", @@ -3591,18 +3564,6 @@ "url": "https://ko-fi.com/killymxi" } }, - "node_modules/@sindresorhus/is": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.0.2.tgz", - "integrity": "sha512-d9xRovfKNz1SKieM0qJdO+PQonjnnIfSNWfHYnBSJ9hkjm0ZPw6HlxscDXYstp3z+7V2GOFHc+J0CYrYTjqCJw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, "node_modules/@sindresorhus/merge-streams": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", @@ -3615,18 +3576,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@szmarczak/http-timer": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", - "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", - "license": "MIT", - "dependencies": { - "defer-to-connect": "^2.0.1" - }, - "engines": { - "node": ">=14.16" - } - }, "node_modules/@textlint/ast-node-types": { "version": "15.2.2", "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.2.2.tgz", @@ -3932,12 +3881,6 @@ "integrity": "sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ==", "license": "MIT" }, - "node_modules/@types/http-cache-semantics": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", - "license": "MIT" - }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -4333,21 +4276,20 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz", - "integrity": "sha512-ijItUYaiWuce0N1SoSMrEd0b6b6lYkYt99pqCPfybd+HKVXtEvYhICfLdwp42MhiI5mp0oq7PKEL+g1cNiz/Eg==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", + "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.35.0", - "@typescript-eslint/type-utils": "8.35.0", - "@typescript-eslint/utils": "8.35.0", - "@typescript-eslint/visitor-keys": "8.35.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/type-utils": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4357,9 +4299,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.35.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -4373,17 +4315,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.0.tgz", - "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", + "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.35.0", - "@typescript-eslint/types": "8.35.0", - "@typescript-eslint/typescript-estree": "8.35.0", - "@typescript-eslint/visitor-keys": "8.35.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4393,20 +4335,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.0.tgz", - "integrity": "sha512-41xatqRwWZuhUMF/aZm2fcUsOFKNcG28xqRSS6ZVr9BVJtGExosLAm5A1OxTjRMagx8nJqva+P5zNIGt8RIgbQ==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", + "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.35.0", - "@typescript-eslint/types": "^8.35.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.58.2", + "@typescript-eslint/types": "^8.58.2", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4416,18 +4358,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.0.tgz", - "integrity": "sha512-+AgL5+mcoLxl1vGjwNfiWq5fLDZM1TmTPYs2UkyHfFhgERxBbqHlNjRzhThJqz+ktBqTChRYY6zwbMwy0591AA==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", + "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.35.0", - "@typescript-eslint/visitor-keys": "8.35.0" + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4438,9 +4380,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.0.tgz", - "integrity": "sha512-04k/7247kZzFraweuEirmvUj+W3bJLI9fX6fbo1Qm2YykuBvEhRTPl8tcxlYO8kZZW+HIXfkZNoasVb8EV4jpA==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", + "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", "dev": true, "license": "MIT", "engines": { @@ -4451,20 +4393,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.0.tgz", - "integrity": "sha512-ceNNttjfmSEoM9PW87bWLDEIaLAyR+E6BoYJQ5PfaDau37UGca9Nyq3lBk8Bw2ad0AKvYabz6wxc7DMTO2jnNA==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", + "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.35.0", - "@typescript-eslint/utils": "8.35.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4474,14 +4417,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.0.tgz", - "integrity": "sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", "dev": true, "license": "MIT", "engines": { @@ -4493,22 +4436,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz", - "integrity": "sha512-F+BhnaBemgu1Qf8oHrxyw14wq6vbL8xwWKKMwTMwYIRmFFY/1n/9T/jpbobZL8vp7QyEUcC6xGrnAO4ua8Kp7w==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", + "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.35.0", - "@typescript-eslint/tsconfig-utils": "8.35.0", - "@typescript-eslint/types": "8.35.0", - "@typescript-eslint/visitor-keys": "8.35.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.58.2", + "@typescript-eslint/tsconfig-utils": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4518,20 +4460,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.0.tgz", - "integrity": "sha512-nqoMu7WWM7ki5tPgLVsmPM8CkqtoPUG6xXGeefM5t4x3XumOEKMoUZPdi+7F+/EotukN4R9OWdmDxN80fqoZeg==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", + "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.35.0", - "@typescript-eslint/types": "8.35.0", - "@typescript-eslint/typescript-estree": "8.35.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4541,19 +4483,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.0.tgz", - "integrity": "sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", + "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.35.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.58.2", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4680,6 +4622,19 @@ "win32" ] }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@typespec/ts-http-runtime": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.0.tgz", @@ -4756,28 +4711,6 @@ } } }, - "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/project-service": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz", - "integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.47.0", - "@typescript-eslint/types": "^8.47.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { "version": "8.47.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz", @@ -4796,23 +4729,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz", - "integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": { "version": "8.47.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz", @@ -4827,59 +4743,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz", - "integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.47.0", - "@typescript-eslint/tsconfig-utils": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz", - "integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { "version": "8.47.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz", @@ -6126,33 +5989,6 @@ "node": ">=8" } }, - "node_modules/cacheable-lookup": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", - "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, - "node_modules/cacheable-request": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-12.0.1.tgz", - "integrity": "sha512-Yo9wGIQUaAfIbk+qY0X4cDQgCosecfBe3V9NSyeY4qPC2SAkbCS4Xj79VP8WOzitpJUZKc/wsRCYF5ariDIwkg==", - "license": "MIT", - "dependencies": { - "@types/http-cache-semantics": "^4.0.4", - "get-stream": "^9.0.1", - "http-cache-semantics": "^4.1.1", - "keyv": "^4.5.4", - "mimic-response": "^4.0.0", - "normalize-url": "^8.0.1", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -7086,33 +6922,6 @@ } } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decompress-response/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -7175,15 +6984,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -8546,9 +8346,9 @@ } }, "node_modules/execa": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz", - "integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", "license": "MIT", "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", @@ -9068,15 +8868,6 @@ "node": ">= 6" } }, - "node_modules/form-data-encoder": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.1.0.tgz", - "integrity": "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==", - "license": "MIT", - "engines": { - "node": ">= 18" - } - }, "node_modules/form-data/node_modules/mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", @@ -9704,43 +9495,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/got": { - "version": "14.4.8", - "resolved": "https://registry.npmjs.org/got/-/got-14.4.8.tgz", - "integrity": "sha512-vxwU4HuR0BIl+zcT1LYrgBjM+IJjNElOjCzs0aPgHorQyr/V6H6Y73Sn3r3FOlUffvWD+Q5jtRuGWaXkU8Jbhg==", - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^7.0.1", - "@szmarczak/http-timer": "^5.0.1", - "cacheable-lookup": "^7.0.0", - "cacheable-request": "^12.0.1", - "decompress-response": "^6.0.0", - "form-data-encoder": "^4.0.2", - "http2-wrapper": "^2.2.1", - "lowercase-keys": "^3.0.0", - "p-cancelable": "^4.0.1", - "responselike": "^3.0.0", - "type-fest": "^4.26.1" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, - "node_modules/got/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -9996,12 +9750,6 @@ "entities": "^4.4.0" } }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "license": "BSD-2-Clause" - }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -10035,19 +9783,6 @@ "node": ">= 14" } }, - "node_modules/http2-wrapper": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", - "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", - "license": "MIT", - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.2.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -11158,6 +10893,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, "license": "MIT" }, "node_modules/json-parse-better-errors": { @@ -11353,6 +11089,7 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "license": "MIT", "dependencies": { "json-buffer": "3.0.1" @@ -11817,18 +11554,6 @@ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "license": "MIT" }, - "node_modules/lowercase-keys": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", - "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/lowlight": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", @@ -12099,18 +11824,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mimic-response": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", - "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -12489,18 +12202,6 @@ "node": "^16.14.0 || >=18.0.0" } }, - "node_modules/normalize-url": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.2.tgz", - "integrity": "sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/npm-normalize-package-bin": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", @@ -13013,15 +12714,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/p-cancelable": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz", - "integrity": "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==", - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -13861,18 +13553,6 @@ ], "license": "MIT" }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -14313,12 +13993,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "license": "MIT" - }, "node_modules/resolve-dir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", @@ -14353,21 +14027,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/responselike": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", - "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", - "license": "MIT", - "dependencies": { - "lowercase-keys": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/restore-cursor": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", @@ -16259,9 +15918,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -16545,6 +16204,237 @@ "typescript": ">=4.8.4 <5.9.0" } }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz", + "integrity": "sha512-ijItUYaiWuce0N1SoSMrEd0b6b6lYkYt99pqCPfybd+HKVXtEvYhICfLdwp42MhiI5mp0oq7PKEL+g1cNiz/Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.35.0", + "@typescript-eslint/type-utils": "8.35.0", + "@typescript-eslint/utils": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.35.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.0.tgz", + "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.35.0", + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/typescript-estree": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/project-service": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.0.tgz", + "integrity": "sha512-41xatqRwWZuhUMF/aZm2fcUsOFKNcG28xqRSS6ZVr9BVJtGExosLAm5A1OxTjRMagx8nJqva+P5zNIGt8RIgbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.35.0", + "@typescript-eslint/types": "^8.35.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.0.tgz", + "integrity": "sha512-+AgL5+mcoLxl1vGjwNfiWq5fLDZM1TmTPYs2UkyHfFhgERxBbqHlNjRzhThJqz+ktBqTChRYY6zwbMwy0591AA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.0.tgz", + "integrity": "sha512-04k/7247kZzFraweuEirmvUj+W3bJLI9fX6fbo1Qm2YykuBvEhRTPl8tcxlYO8kZZW+HIXfkZNoasVb8EV4jpA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.0.tgz", + "integrity": "sha512-ceNNttjfmSEoM9PW87bWLDEIaLAyR+E6BoYJQ5PfaDau37UGca9Nyq3lBk8Bw2ad0AKvYabz6wxc7DMTO2jnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.35.0", + "@typescript-eslint/utils": "8.35.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.0.tgz", + "integrity": "sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz", + "integrity": "sha512-F+BhnaBemgu1Qf8oHrxyw14wq6vbL8xwWKKMwTMwYIRmFFY/1n/9T/jpbobZL8vp7QyEUcC6xGrnAO4ua8Kp7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.35.0", + "@typescript-eslint/tsconfig-utils": "8.35.0", + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.0.tgz", + "integrity": "sha512-nqoMu7WWM7ki5tPgLVsmPM8CkqtoPUG6xXGeefM5t4x3XumOEKMoUZPdi+7F+/EotukN4R9OWdmDxN80fqoZeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.35.0", + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/typescript-estree": "8.35.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.0.tgz", + "integrity": "sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.35.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", @@ -17763,18 +17653,6 @@ } } }, - "node_modules/xdg-basedir": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", - "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/xml2js": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", @@ -17983,7 +17861,7 @@ }, "packages/a2a-server": { "name": "@google/gemini-cli-a2a-server", - "version": "0.39.0-nightly.20260408.e77b22e63", + "version": "0.40.0-nightly.20260414.g5b1f7375a", "dependencies": { "@a2a-js/sdk": "0.3.11", "@google-cloud/storage": "^7.16.0", @@ -18083,6 +17961,20 @@ "node": ">=18" } }, + "packages/a2a-server/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "packages/a2a-server/node_modules/uuid": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", @@ -18098,7 +17990,7 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.39.0-nightly.20260408.e77b22e63", + "version": "0.40.0-nightly.20260414.g5b1f7375a", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^0.16.1", @@ -18198,44 +18090,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "packages/cli/node_modules/execa": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", - "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "cross-spawn": "^7.0.6", - "figures": "^6.1.0", - "get-stream": "^9.0.0", - "human-signals": "^8.0.1", - "is-plain-obj": "^4.1.0", - "is-stream": "^4.0.1", - "npm-run-path": "^6.0.0", - "pretty-ms": "^9.2.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^4.0.0", - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": "^18.19.0 || >=20.5.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "packages/cli/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "packages/cli/node_modules/string-width": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", @@ -18268,9 +18122,23 @@ "node": ">=18" } }, + "packages/cli/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.39.0-nightly.20260408.e77b22e63", + "version": "0.40.0-nightly.20260414.g5b1f7375a", "license": "Apache-2.0", "dependencies": { "@a2a-js/sdk": "0.3.11", @@ -18281,7 +18149,6 @@ "@google/genai": "1.30.0", "@grpc/grpc-js": "^1.14.3", "@iarna/toml": "^2.2.5", - "@joshua.litt/get-ripgrep": "^0.0.3", "@modelcontextprotocol/sdk": "^1.23.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.211.0", @@ -18309,6 +18176,7 @@ "diff": "^8.0.3", "dotenv": "^17.2.4", "dotenv-expand": "^12.0.3", + "execa": "^9.6.1", "fast-levenshtein": "^2.0.6", "fdir": "^6.4.6", "fzf": "^0.5.2", @@ -18522,6 +18390,20 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "packages/core/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "packages/core/node_modules/uuid": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", @@ -18537,7 +18419,7 @@ }, "packages/devtools": { "name": "@google/gemini-cli-devtools", - "version": "0.39.0-nightly.20260408.e77b22e63", + "version": "0.40.0-nightly.20260414.g5b1f7375a", "license": "Apache-2.0", "dependencies": { "ws": "^8.16.0" @@ -18553,7 +18435,7 @@ }, "packages/sdk": { "name": "@google/gemini-cli-sdk", - "version": "0.39.0-nightly.20260408.e77b22e63", + "version": "0.40.0-nightly.20260414.g5b1f7375a", "license": "Apache-2.0", "dependencies": { "@google/gemini-cli-core": "file:../core", @@ -18568,9 +18450,23 @@ "node": ">=20" } }, + "packages/sdk/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", - "version": "0.39.0-nightly.20260408.e77b22e63", + "version": "0.40.0-nightly.20260414.g5b1f7375a", "license": "Apache-2.0", "dependencies": { "@google/gemini-cli-core": "file:../core", @@ -18586,9 +18482,23 @@ "node": ">=20" } }, + "packages/test-utils/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", - "version": "0.39.0-nightly.20260408.e77b22e63", + "version": "0.40.0-nightly.20260414.g5b1f7375a", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0", @@ -18620,6 +18530,20 @@ "integrity": "sha512-30sjmas1hQ0gVbX68LAWlm/YYlEqUErunPJJKLpEl+xhK0mKn+jyzlCOpsdTwfkZfPy4U6CDkmygBLC3AB8W9Q==", "dev": true, "license": "MIT" + }, + "packages/vscode-ide-companion/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } } } } diff --git a/package.json b/package.json index c233e120ec..aa71b9070c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.39.0-nightly.20260408.e77b22e63", + "version": "0.40.0-nightly.20260414.g5b1f7375a", "engines": { "node": ">=20.0.0" }, @@ -14,7 +14,7 @@ "url": "git+https://github.com/google-gemini/gemini-cli.git" }, "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a" }, "scripts": { "start": "cross-env NODE_ENV=development node scripts/start.js", @@ -62,7 +62,7 @@ "lint:ci": "npm run lint:all", "lint:all": "node scripts/lint.js", "format": "prettier --experimental-cli --write .", - "typecheck": "npm run typecheck --workspaces --if-present", + "typecheck": "npm run typecheck --workspaces --if-present && tsc -b evals/tsconfig.json integration-tests/tsconfig.json memory-tests/tsconfig.json", "preflight": "npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck && npm run test:ci", "prepare": "husky && npm run bundle", "prepare:package": "node scripts/prepare-package.js", @@ -94,6 +94,7 @@ ], "devDependencies": { "@agentclientprotocol/sdk": "^0.16.1", + "read-package-up": "^11.0.0", "@octokit/rest": "^22.0.0", "@types/marked": "^5.0.2", "@types/mime-types": "^3.0.1", @@ -138,6 +139,7 @@ "strip-ansi": "^7.1.2", "ts-prune": "^0.10.3", "tsx": "^4.20.3", + "typescript": "^5.8.3", "typescript-eslint": "^8.30.1", "vitest": "^3.2.4", "yargs": "^17.7.2" diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index b933a8fcd0..2beda898f6 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-a2a-server", - "version": "0.39.0-nightly.20260408.e77b22e63", + "version": "0.40.0-nightly.20260414.g5b1f7375a", "description": "Gemini CLI A2A Server", "repository": { "type": "git", diff --git a/packages/cli/package.json b/packages/cli/package.json index f507eed041..8db47eecc1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.39.0-nightly.20260408.e77b22e63", + "version": "0.40.0-nightly.20260414.g5b1f7375a", "description": "Gemini CLI", "license": "Apache-2.0", "repository": { @@ -27,7 +27,7 @@ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a" }, "dependencies": { "@agentclientprotocol/sdk": "^0.16.1", diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index fcfd604e3a..ae7d1ebdf5 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -256,14 +256,29 @@ const SETTINGS_SCHEMA = { }, enableNotifications: { type: 'boolean', - label: 'Enable Notifications', + label: 'Enable Terminal Notifications', category: 'General', requiresRestart: false, default: false, description: - 'Enable run-event notifications for action-required prompts and session completion.', + 'Enable terminal run-event notifications for action-required prompts and session completion.', showInDialog: true, }, + notificationMethod: { + type: 'enum', + label: 'Terminal Notification Method', + category: 'General', + requiresRestart: false, + default: 'auto', + description: 'How to send terminal notifications.', + showInDialog: true, + options: [ + { value: 'auto', label: 'Auto' }, + { value: 'osc9', label: 'OSC 9' }, + { value: 'osc777', label: 'OSC 777' }, + { value: 'bell', label: 'Bell' }, + ], + }, checkpointing: { type: 'object', label: 'Checkpointing', diff --git a/packages/cli/src/services/SkillCommandLoader.test.ts b/packages/cli/src/services/SkillCommandLoader.test.ts index 51cc098536..6c7861ce6b 100644 --- a/packages/cli/src/services/SkillCommandLoader.test.ts +++ b/packages/cli/src/services/SkillCommandLoader.test.ts @@ -88,7 +88,7 @@ describe('SkillCommandLoader', () => { type: 'tool', toolName: ACTIVATE_SKILL_TOOL_NAME, toolArgs: { name: 'test-skill' }, - postSubmitPrompt: undefined, + postSubmitPrompt: 'Use the skill test-skill', }); }); diff --git a/packages/cli/src/services/SkillCommandLoader.ts b/packages/cli/src/services/SkillCommandLoader.ts index e264da2e31..ea51b37735 100644 --- a/packages/cli/src/services/SkillCommandLoader.ts +++ b/packages/cli/src/services/SkillCommandLoader.ts @@ -46,7 +46,10 @@ export class SkillCommandLoader implements ICommandLoader { type: 'tool', toolName: ACTIVATE_SKILL_TOOL_NAME, toolArgs: { name: skill.name }, - postSubmitPrompt: args.trim().length > 0 ? args.trim() : undefined, + postSubmitPrompt: + args.trim().length > 0 + ? args.trim() + : `Use the skill ${skill.name}`, }), }; }); diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index 6561ac1db0..516be675c0 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -44,6 +44,7 @@ export const createMockConfig = (overrides: Partial = {}): Config => getListSessions: vi.fn(() => false), getDeleteSession: vi.fn(() => undefined), setSessionId: vi.fn(), + resetNewSessionState: vi.fn(), getSessionId: vi.fn().mockReturnValue('mock-session-id'), getWorktreeSettings: vi.fn(() => undefined), getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })), diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 8f05b996dc..92a519856a 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -53,6 +53,7 @@ const mocks = vi.hoisted(() => ({ const terminalNotificationsMocks = vi.hoisted(() => ({ notifyViaTerminal: vi.fn().mockResolvedValue(true), isNotificationsEnabled: vi.fn(() => true), + getNotificationMethod: vi.fn(() => 'auto'), buildRunEventNotificationContent: vi.fn((event) => ({ title: 'Mock Notification', subtitle: 'Mock Subtitle', @@ -194,6 +195,7 @@ vi.mock('./hooks/useShellInactivityStatus.js', () => ({ vi.mock('../utils/terminalNotifications.js', () => ({ notifyViaTerminal: terminalNotificationsMocks.notifyViaTerminal, isNotificationsEnabled: terminalNotificationsMocks.isNotificationsEnabled, + getNotificationMethod: terminalNotificationsMocks.getNotificationMethod, buildRunEventNotificationContent: terminalNotificationsMocks.buildRunEventNotificationContent, })); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index f17ac0d756..4d7675bea3 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -181,7 +181,10 @@ import { useTimedMessage } from './hooks/useTimedMessage.js'; import { useIsHelpDismissKey } from './utils/shortcutsHelp.js'; import { useSuspend } from './hooks/useSuspend.js'; import { useRunEventNotifications } from './hooks/useRunEventNotifications.js'; -import { isNotificationsEnabled } from '../utils/terminalNotifications.js'; +import { + isNotificationsEnabled, + getNotificationMethod, +} from '../utils/terminalNotifications.js'; import { getLastTurnToolCallIds, isToolExecuting, @@ -225,6 +228,7 @@ export const AppContainer = (props: AppContainerProps) => { const settings = useSettings(); const { reset } = useOverflowActions()!; const notificationsEnabled = isNotificationsEnabled(settings); + const notificationMethod = getNotificationMethod(settings); const { setOptions, dumpCurrentFrame, startRecording, stopRecording } = useContext(InkAppContext); @@ -973,6 +977,7 @@ Logging in with Google... Restarting Gemini CLI to continue. openAgentConfigDialog, openPermissionsDialog, quit: (messages: HistoryItem[]) => { + closeThemeDialog(); setQuittingMessages(messages); setTimeout(async () => { await runExitCleanup(); @@ -1001,6 +1006,7 @@ Logging in with Google... Restarting Gemini CLI to continue. [ setAuthState, openThemeDialog, + closeThemeDialog, openEditorDialog, openSettingsDialog, openSessionBrowser, @@ -2284,6 +2290,7 @@ Logging in with Google... Restarting Gemini CLI to continue. useRunEventNotifications({ notificationsEnabled, + notificationMethod, isFocused, hasReceivedFocusEvent, streamingState, diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index 77f6e4854d..51e3ace2f5 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -39,7 +39,7 @@ describe('clearCommand', () => { agentContext: { config: { getEnableHooks: vi.fn().mockReturnValue(false), - setSessionId: vi.fn(), + resetNewSessionState: vi.fn(), getMessageBus: vi.fn().mockReturnValue(undefined), getHookSystem: vi.fn().mockReturnValue({ fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), @@ -74,6 +74,9 @@ describe('clearCommand', () => { expect(mockResetChat).toHaveBeenCalledTimes(1); expect(mockHintClear).toHaveBeenCalledTimes(1); + expect( + mockContext.services.agentContext?.config.resetNewSessionState, + ).toHaveBeenCalledTimes(1); expect(uiTelemetryService.clear).toHaveBeenCalled(); expect(uiTelemetryService.clear).toHaveBeenCalledTimes(1); expect(mockContext.ui.clear).toHaveBeenCalledTimes(1); diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index fb032da811..8e5deafd01 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -39,7 +39,7 @@ export const clearCommand: SlashCommand = { let newSessionId: string | undefined; if (config) { newSessionId = randomUUID(); - config.setSessionId(newSessionId); + config.resetNewSessionState(newSessionId); } if (geminiClient) { diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg index a9673bc3b7..a07095d37b 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg @@ -67,47 +67,47 @@ - Enable Notifications + Enable Terminal Notifications false - Enable run-event notifications for action-required prompts and session completion. + Enable terminal run-event notifications for action-required prompts and session com… - Enable Plan Mode - true + Terminal Notification Method + Auto - Enable Plan Mode for read-only safety during planning. + How to send terminal notifications. - Plan Directory - undefined + Enable Plan Mode + true - The directory where planning artifacts are stored. If not specified, defaults t… + Enable Plan Mode for read-only safety during planning. - Plan Model Routing - true + Plan Directory + undefined - Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… + The directory where planning artifacts are stored. If not specified, defaults t… - Retry Fetch Errors + Plan Model Routing true - Retry on "exception TypeError: fetch failed sending request" errors. + Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg index 72a11cad81..e90fa4363e 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg @@ -67,47 +67,47 @@ - Enable Notifications + Enable Terminal Notifications false - Enable run-event notifications for action-required prompts and session completion. + Enable terminal run-event notifications for action-required prompts and session com… - Enable Plan Mode - true + Terminal Notification Method + Auto - Enable Plan Mode for read-only safety during planning. + How to send terminal notifications. - Plan Directory - undefined + Enable Plan Mode + true - The directory where planning artifacts are stored. If not specified, defaults t… + Enable Plan Mode for read-only safety during planning. - Plan Model Routing - true + Plan Directory + undefined - Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… + The directory where planning artifacts are stored. If not specified, defaults t… - Retry Fetch Errors + Plan Model Routing true - Retry on "exception TypeError: fetch failed sending request" errors. + Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg index 8f4daa80ae..2a30e9f212 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg @@ -67,47 +67,47 @@ - Enable Notifications + Enable Terminal Notifications false - Enable run-event notifications for action-required prompts and session completion. + Enable terminal run-event notifications for action-required prompts and session com… - Enable Plan Mode - true + Terminal Notification Method + Auto - Enable Plan Mode for read-only safety during planning. + How to send terminal notifications. - Plan Directory - undefined + Enable Plan Mode + true - The directory where planning artifacts are stored. If not specified, defaults t… + Enable Plan Mode for read-only safety during planning. - Plan Model Routing - true + Plan Directory + undefined - Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… + The directory where planning artifacts are stored. If not specified, defaults t… - Retry Fetch Errors + Plan Model Routing true - Retry on "exception TypeError: fetch failed sending request" errors. + Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg index a9673bc3b7..a07095d37b 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg @@ -67,47 +67,47 @@ - Enable Notifications + Enable Terminal Notifications false - Enable run-event notifications for action-required prompts and session completion. + Enable terminal run-event notifications for action-required prompts and session com… - Enable Plan Mode - true + Terminal Notification Method + Auto - Enable Plan Mode for read-only safety during planning. + How to send terminal notifications. - Plan Directory - undefined + Enable Plan Mode + true - The directory where planning artifacts are stored. If not specified, defaults t… + Enable Plan Mode for read-only safety during planning. - Plan Model Routing - true + Plan Directory + undefined - Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… + The directory where planning artifacts are stored. If not specified, defaults t… - Retry Fetch Errors + Plan Model Routing true - Retry on "exception TypeError: fetch failed sending request" errors. + Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg index a9673bc3b7..a07095d37b 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg @@ -67,47 +67,47 @@ - Enable Notifications + Enable Terminal Notifications false - Enable run-event notifications for action-required prompts and session completion. + Enable terminal run-event notifications for action-required prompts and session com… - Enable Plan Mode - true + Terminal Notification Method + Auto - Enable Plan Mode for read-only safety during planning. + How to send terminal notifications. - Plan Directory - undefined + Enable Plan Mode + true - The directory where planning artifacts are stored. If not specified, defaults t… + Enable Plan Mode for read-only safety during planning. - Plan Model Routing - true + Plan Directory + undefined - Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… + The directory where planning artifacts are stored. If not specified, defaults t… - Retry Fetch Errors + Plan Model Routing true - Retry on "exception TypeError: fetch failed sending request" errors. + Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg index 4068847a9c..31b81aa227 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg @@ -56,47 +56,47 @@ - Enable Notifications + Enable Terminal Notifications false - Enable run-event notifications for action-required prompts and session completion. + Enable terminal run-event notifications for action-required prompts and session com… - Enable Plan Mode - true + Terminal Notification Method + Auto - Enable Plan Mode for read-only safety during planning. + How to send terminal notifications. - Plan Directory - undefined + Enable Plan Mode + true - The directory where planning artifacts are stored. If not specified, defaults t… + Enable Plan Mode for read-only safety during planning. - Plan Model Routing - true + Plan Directory + undefined - Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… + The directory where planning artifacts are stored. If not specified, defaults t… - Retry Fetch Errors + Plan Model Routing true - Retry on "exception TypeError: fetch failed sending request" errors. + Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg index 93ba308209..926eaef5e7 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg @@ -67,47 +67,47 @@ - Enable Notifications + Enable Terminal Notifications false - Enable run-event notifications for action-required prompts and session completion. + Enable terminal run-event notifications for action-required prompts and session com… - Enable Plan Mode - true + Terminal Notification Method + Auto - Enable Plan Mode for read-only safety during planning. + How to send terminal notifications. - Plan Directory - undefined + Enable Plan Mode + true - The directory where planning artifacts are stored. If not specified, defaults t… + Enable Plan Mode for read-only safety during planning. - Plan Model Routing - true + Plan Directory + undefined - Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… + The directory where planning artifacts are stored. If not specified, defaults t… - Retry Fetch Errors + Plan Model Routing true - Retry on "exception TypeError: fetch failed sending request" errors. + Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg index a9673bc3b7..a07095d37b 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg @@ -67,47 +67,47 @@ - Enable Notifications + Enable Terminal Notifications false - Enable run-event notifications for action-required prompts and session completion. + Enable terminal run-event notifications for action-required prompts and session com… - Enable Plan Mode - true + Terminal Notification Method + Auto - Enable Plan Mode for read-only safety during planning. + How to send terminal notifications. - Plan Directory - undefined + Enable Plan Mode + true - The directory where planning artifacts are stored. If not specified, defaults t… + Enable Plan Mode for read-only safety during planning. - Plan Model Routing - true + Plan Directory + undefined - Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… + The directory where planning artifacts are stored. If not specified, defaults t… - Retry Fetch Errors + Plan Model Routing true - Retry on "exception TypeError: fetch failed sending request" errors. + Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg index b49d53d02c..54288835ea 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg @@ -67,47 +67,47 @@ - Enable Notifications + Enable Terminal Notifications false - Enable run-event notifications for action-required prompts and session completion. + Enable terminal run-event notifications for action-required prompts and session com… - Enable Plan Mode - true + Terminal Notification Method + Auto - Enable Plan Mode for read-only safety during planning. + How to send terminal notifications. - Plan Directory - undefined + Enable Plan Mode + true - The directory where planning artifacts are stored. If not specified, defaults t… + Enable Plan Mode for read-only safety during planning. - Plan Model Routing - true + Plan Directory + undefined - Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… + The directory where planning artifacts are stored. If not specified, defaults t… - Retry Fetch Errors + Plan Model Routing true - Retry on "exception TypeError: fetch failed sending request" errors. + Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index d585c9a918..a7f994ed68 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -19,8 +19,11 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v │ Enable Auto Update true │ │ Enable automatic updates. │ │ │ -│ Enable Notifications false │ -│ Enable run-event notifications for action-required prompts and session completion. │ +│ Enable Terminal Notifications false │ +│ Enable terminal run-event notifications for action-required prompts and session com… │ +│ │ +│ Terminal Notification Method Auto │ +│ How to send terminal notifications. │ │ │ │ Enable Plan Mode true │ │ Enable Plan Mode for read-only safety during planning. │ @@ -31,9 +34,6 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v │ Plan Model Routing true │ │ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │ │ │ -│ Retry Fetch Errors true │ -│ Retry on "exception TypeError: fetch failed sending request" errors. │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -65,8 +65,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings │ Enable Auto Update true │ │ Enable automatic updates. │ │ │ -│ Enable Notifications false │ -│ Enable run-event notifications for action-required prompts and session completion. │ +│ Enable Terminal Notifications false │ +│ Enable terminal run-event notifications for action-required prompts and session com… │ +│ │ +│ Terminal Notification Method Auto │ +│ How to send terminal notifications. │ │ │ │ Enable Plan Mode true │ │ Enable Plan Mode for read-only safety during planning. │ @@ -77,9 +80,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings │ Plan Model Routing true │ │ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │ │ │ -│ Retry Fetch Errors true │ -│ Retry on "exception TypeError: fetch failed sending request" errors. │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -111,8 +111,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d │ Enable Auto Update true* │ │ Enable automatic updates. │ │ │ -│ Enable Notifications false │ -│ Enable run-event notifications for action-required prompts and session completion. │ +│ Enable Terminal Notifications false │ +│ Enable terminal run-event notifications for action-required prompts and session com… │ +│ │ +│ Terminal Notification Method Auto │ +│ How to send terminal notifications. │ │ │ │ Enable Plan Mode true │ │ Enable Plan Mode for read-only safety during planning. │ @@ -123,9 +126,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d │ Plan Model Routing true │ │ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │ │ │ -│ Retry Fetch Errors true │ -│ Retry on "exception TypeError: fetch failed sending request" errors. │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -157,8 +157,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct │ Enable Auto Update true │ │ Enable automatic updates. │ │ │ -│ Enable Notifications false │ -│ Enable run-event notifications for action-required prompts and session completion. │ +│ Enable Terminal Notifications false │ +│ Enable terminal run-event notifications for action-required prompts and session com… │ +│ │ +│ Terminal Notification Method Auto │ +│ How to send terminal notifications. │ │ │ │ Enable Plan Mode true │ │ Enable Plan Mode for read-only safety during planning. │ @@ -169,9 +172,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct │ Plan Model Routing true │ │ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │ │ │ -│ Retry Fetch Errors true │ -│ Retry on "exception TypeError: fetch failed sending request" errors. │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -203,8 +203,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting │ Enable Auto Update true │ │ Enable automatic updates. │ │ │ -│ Enable Notifications false │ -│ Enable run-event notifications for action-required prompts and session completion. │ +│ Enable Terminal Notifications false │ +│ Enable terminal run-event notifications for action-required prompts and session com… │ +│ │ +│ Terminal Notification Method Auto │ +│ How to send terminal notifications. │ │ │ │ Enable Plan Mode true │ │ Enable Plan Mode for read-only safety during planning. │ @@ -215,9 +218,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting │ Plan Model Routing true │ │ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │ │ │ -│ Retry Fetch Errors true │ -│ Retry on "exception TypeError: fetch failed sending request" errors. │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -249,8 +249,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ Enable Auto Update true │ │ Enable automatic updates. │ │ │ -│ Enable Notifications false │ -│ Enable run-event notifications for action-required prompts and session completion. │ +│ Enable Terminal Notifications false │ +│ Enable terminal run-event notifications for action-required prompts and session com… │ +│ │ +│ Terminal Notification Method Auto │ +│ How to send terminal notifications. │ │ │ │ Enable Plan Mode true │ │ Enable Plan Mode for read-only safety during planning. │ @@ -261,9 +264,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ Plan Model Routing true │ │ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │ │ │ -│ Retry Fetch Errors true │ -│ Retry on "exception TypeError: fetch failed sending request" errors. │ -│ │ │ ▼ │ │ │ │ > Apply To │ @@ -295,8 +295,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb │ Enable Auto Update false* │ │ Enable automatic updates. │ │ │ -│ Enable Notifications false │ -│ Enable run-event notifications for action-required prompts and session completion. │ +│ Enable Terminal Notifications false │ +│ Enable terminal run-event notifications for action-required prompts and session com… │ +│ │ +│ Terminal Notification Method Auto │ +│ How to send terminal notifications. │ │ │ │ Enable Plan Mode true │ │ Enable Plan Mode for read-only safety during planning. │ @@ -307,9 +310,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb │ Plan Model Routing true │ │ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │ │ │ -│ Retry Fetch Errors true │ -│ Retry on "exception TypeError: fetch failed sending request" errors. │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -341,8 +341,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set │ Enable Auto Update true │ │ Enable automatic updates. │ │ │ -│ Enable Notifications false │ -│ Enable run-event notifications for action-required prompts and session completion. │ +│ Enable Terminal Notifications false │ +│ Enable terminal run-event notifications for action-required prompts and session com… │ +│ │ +│ Terminal Notification Method Auto │ +│ How to send terminal notifications. │ │ │ │ Enable Plan Mode true │ │ Enable Plan Mode for read-only safety during planning. │ @@ -353,9 +356,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set │ Plan Model Routing true │ │ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │ │ │ -│ Retry Fetch Errors true │ -│ Retry on "exception TypeError: fetch failed sending request" errors. │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -387,8 +387,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin │ Enable Auto Update false* │ │ Enable automatic updates. │ │ │ -│ Enable Notifications false │ -│ Enable run-event notifications for action-required prompts and session completion. │ +│ Enable Terminal Notifications false │ +│ Enable terminal run-event notifications for action-required prompts and session com… │ +│ │ +│ Terminal Notification Method Auto │ +│ How to send terminal notifications. │ │ │ │ Enable Plan Mode true │ │ Enable Plan Mode for read-only safety during planning. │ @@ -399,9 +402,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin │ Plan Model Routing true │ │ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │ │ │ -│ Retry Fetch Errors true │ -│ Retry on "exception TypeError: fetch failed sending request" errors. │ -│ │ │ ▼ │ │ │ │ Apply To │ diff --git a/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap index 2b9090e237..258e994bfa 100644 --- a/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap @@ -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) │ │ diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 2e0406ee4e..f71f3e7800 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -246,11 +246,10 @@ export const ToolGroupMessage: React.FC = ({ (showClosingBorder ? 1 : 0); } else if (isTopicToolCall) { // Topic Message Spacing Breakdown: - // 1. Top Margin (1): Always present for spacing. - // 2. Topic Content (1). - // 3. Bottom Margin (1): Always present around TopicMessage for breathing room. - // 4. Closing Border (1): Added if transition logic (showClosingBorder) requires it. - height += 1 + 1 + 1 + (showClosingBorder ? 1 : 0); + // 1. Topic Content (1). + // 2. Bottom Margin (1): Always present around TopicMessage for breathing room. + // 3. Closing Border (1): Added if transition logic (showClosingBorder) requires it. + height += 1 + 1 + (showClosingBorder ? 1 : 0); } else if (isCompact) { // Compact Tool: Always renders as a single dense line. height += 1; @@ -439,7 +438,7 @@ export const ToolGroupMessage: React.FC = ({ {isCompact ? ( ) : isTopicToolCall ? ( - + ) : isShellToolCall ? ( diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap index 270f8e1b8f..f61b9274c9 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap @@ -77,8 +77,7 @@ exports[` > Golden Snapshots > renders header when scrolled `; exports[` > Golden Snapshots > renders mixed tool calls including update_topic 1`] = ` -" - Testing Topic: This is the description +" Testing Topic: This is the description ╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ read_file Read a file │ @@ -143,8 +142,7 @@ exports[` > Golden Snapshots > renders two tool groups where `; exports[` > Golden Snapshots > renders update_topic tool call using TopicMessage > update_topic_tool 1`] = ` -" - Testing Topic: This is the description +" Testing Topic: This is the description " `; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index ec4aa00677..3e521a6627 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -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); }); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index f55503ad25..20de86002c 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -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); diff --git a/packages/cli/src/ui/hooks/useRunEventNotifications.ts b/packages/cli/src/ui/hooks/useRunEventNotifications.ts index 3051847afb..8dea01f0d2 100644 --- a/packages/cli/src/ui/hooks/useRunEventNotifications.ts +++ b/packages/cli/src/ui/hooks/useRunEventNotifications.ts @@ -15,12 +15,14 @@ import { getPendingAttentionNotification } from '../utils/pendingAttentionNotifi import { buildRunEventNotificationContent, notifyViaTerminal, + type TerminalNotificationMethod, } from '../../utils/terminalNotifications.js'; const ATTENTION_NOTIFICATION_COOLDOWN_MS = 20_000; interface RunEventNotificationParams { notificationsEnabled: boolean; + notificationMethod: TerminalNotificationMethod; isFocused: boolean; hasReceivedFocusEvent: boolean; streamingState: StreamingState; @@ -36,6 +38,7 @@ interface RunEventNotificationParams { export function useRunEventNotifications({ notificationsEnabled, + notificationMethod, isFocused, hasReceivedFocusEvent, streamingState, @@ -124,11 +127,13 @@ export function useRunEventNotifications({ void notifyViaTerminal( notificationsEnabled, buildRunEventNotificationContent(pendingAttentionNotification.event), + notificationMethod, ); }, [ isFocused, hasReceivedFocusEvent, notificationsEnabled, + notificationMethod, pendingAttentionNotification, ]); @@ -159,12 +164,14 @@ export function useRunEventNotifications({ type: 'session_complete', detail: 'Gemini CLI finished responding.', }), + notificationMethod, ); }, [ streamingState, isFocused, hasReceivedFocusEvent, notificationsEnabled, + notificationMethod, hasPendingActionRequired, ]); } diff --git a/packages/cli/src/ui/themes/builtin/dark/github-dark-colorblind.ts b/packages/cli/src/ui/themes/builtin/dark/github-dark-colorblind.ts new file mode 100644 index 0000000000..1af6fba87b --- /dev/null +++ b/packages/cli/src/ui/themes/builtin/dark/github-dark-colorblind.ts @@ -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, +); diff --git a/packages/cli/src/ui/themes/builtin/light/github-light-colorblind.ts b/packages/cli/src/ui/themes/builtin/light/github-light-colorblind.ts new file mode 100644 index 0000000000..eb36fd32d8 --- /dev/null +++ b/packages/cli/src/ui/themes/builtin/light/github-light-colorblind.ts @@ -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, +); diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts index 9f0a7e528a..83848142d6 100644 --- a/packages/cli/src/ui/themes/theme-manager.ts +++ b/packages/cli/src/ui/themes/theme-manager.ts @@ -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, diff --git a/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts b/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts index 4cc25586b5..261b0e6ca9 100644 --- a/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts +++ b/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts @@ -365,76 +365,123 @@ describe('TerminalCapabilityManager', () => { ); }); - describe('supportsOsc9Notifications', () => { + describe('isTmux', () => { const manager = TerminalCapabilityManager.getInstance(); - it.each([ - { - name: 'WezTerm (terminal name)', - terminalName: 'WezTerm', - env: {}, - expected: true, - }, - { - name: 'iTerm.app (terminal name)', - terminalName: 'iTerm.app', - env: {}, - expected: true, - }, - { - name: 'ghostty (terminal name)', - terminalName: 'ghostty', - env: {}, - expected: true, - }, - { - name: 'kitty (terminal name)', - terminalName: 'kitty', - env: {}, - expected: true, - }, - { - name: 'some-other-term (terminal name)', - terminalName: 'some-other-term', - env: {}, - expected: false, - }, - { - name: 'iTerm.app (TERM_PROGRAM)', - terminalName: undefined, - env: { TERM_PROGRAM: 'iTerm.app' }, - expected: true, - }, - { - name: 'vscode (TERM_PROGRAM)', - terminalName: undefined, - env: { TERM_PROGRAM: 'vscode' }, - expected: false, - }, - { - name: 'xterm-kitty (TERM)', - terminalName: undefined, - env: { TERM: 'xterm-kitty' }, - expected: true, - }, - { - name: 'xterm-256color (TERM)', - terminalName: undefined, - env: { TERM: 'xterm-256color' }, - expected: false, - }, - { - name: 'Windows Terminal (WT_SESSION)', - terminalName: 'iTerm.app', - env: { WT_SESSION: 'some-guid' }, - expected: false, - }, - ])( - 'should return $expected for $name', - ({ terminalName, env, expected }) => { - vi.spyOn(manager, 'getTerminalName').mockReturnValue(terminalName); - expect(manager.supportsOsc9Notifications(env)).toBe(expected); - }, - ); + it('returns true when TMUX is set', () => { + expect(manager.isTmux({ TMUX: '1' })).toBe(true); + expect(manager.isTmux({ TMUX: 'tmux-1234' })).toBe(true); + }); + + it('returns false when TMUX is not set', () => { + expect(manager.isTmux({})).toBe(false); + expect(manager.isTmux({ STY: '1' })).toBe(false); + }); + }); + + describe('isScreen', () => { + const manager = TerminalCapabilityManager.getInstance(); + + it('returns true when STY is set', () => { + expect(manager.isScreen({ STY: '1' })).toBe(true); + expect(manager.isScreen({ STY: 'screen.1234' })).toBe(true); + }); + + it('returns false when STY is not set', () => { + expect(manager.isScreen({})).toBe(false); + expect(manager.isScreen({ TMUX: '1' })).toBe(false); + }); + }); + + describe('isITerm2', () => { + const manager = TerminalCapabilityManager.getInstance(); + + it('returns true when iTerm is in terminal name', () => { + vi.spyOn(manager, 'getTerminalName').mockReturnValue('iTerm.app'); + expect(manager.isITerm2({})).toBe(true); + }); + + it('returns true when TERM_PROGRAM is iTerm.app', () => { + vi.spyOn(manager, 'getTerminalName').mockReturnValue(undefined); + expect(manager.isITerm2({ TERM_PROGRAM: 'iTerm.app' })).toBe(true); + }); + + it('returns false otherwise', () => { + vi.spyOn(manager, 'getTerminalName').mockReturnValue('xterm'); + expect(manager.isITerm2({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(false); + }); + }); + + describe('isAlacritty', () => { + const manager = TerminalCapabilityManager.getInstance(); + + it('returns true when ALACRITTY_WINDOW_ID is set', () => { + vi.spyOn(manager, 'getTerminalName').mockReturnValue(undefined); + expect(manager.isAlacritty({ ALACRITTY_WINDOW_ID: '123' })).toBe(true); + }); + + it('returns true when TERM is alacritty', () => { + vi.spyOn(manager, 'getTerminalName').mockReturnValue(undefined); + expect(manager.isAlacritty({ TERM: 'alacritty' })).toBe(true); + }); + + it('returns true when terminal name contains alacritty', () => { + vi.spyOn(manager, 'getTerminalName').mockReturnValue('alacritty'); + expect(manager.isAlacritty({})).toBe(true); + }); + + it('returns false otherwise', () => { + vi.spyOn(manager, 'getTerminalName').mockReturnValue(undefined); + expect(manager.isAlacritty({ TERM: 'xterm' })).toBe(false); + }); + }); + + describe('isAppleTerminal', () => { + const manager = TerminalCapabilityManager.getInstance(); + + it('returns true when apple_terminal is in terminal name', () => { + vi.spyOn(manager, 'getTerminalName').mockReturnValue('apple_terminal'); + expect(manager.isAppleTerminal({})).toBe(true); + }); + + it('returns true when TERM_PROGRAM is Apple_Terminal', () => { + vi.spyOn(manager, 'getTerminalName').mockReturnValue(undefined); + expect(manager.isAppleTerminal({ TERM_PROGRAM: 'Apple_Terminal' })).toBe( + true, + ); + }); + + it('returns false otherwise', () => { + vi.spyOn(manager, 'getTerminalName').mockReturnValue('xterm'); + expect(manager.isAppleTerminal({ TERM_PROGRAM: 'iTerm.app' })).toBe( + false, + ); + }); + }); + + describe('isVSCodeTerminal', () => { + const manager = TerminalCapabilityManager.getInstance(); + + it('returns true when TERM_PROGRAM is vscode', () => { + expect(manager.isVSCodeTerminal({ TERM_PROGRAM: 'vscode' })).toBe(true); + }); + + it('returns false otherwise', () => { + expect(manager.isVSCodeTerminal({ TERM_PROGRAM: 'iTerm.app' })).toBe( + false, + ); + }); + }); + + describe('isWindowsTerminal', () => { + const manager = TerminalCapabilityManager.getInstance(); + + it('returns true when WT_SESSION is set', () => { + expect(manager.isWindowsTerminal({ WT_SESSION: 'some-guid' })).toBe(true); + }); + + it('returns false otherwise', () => { + expect(manager.isWindowsTerminal({})).toBe(false); + }); }); }); diff --git a/packages/cli/src/ui/utils/terminalCapabilityManager.ts b/packages/cli/src/ui/utils/terminalCapabilityManager.ts index ddbbad4ce8..e0fc6c01b8 100644 --- a/packages/cli/src/ui/utils/terminalCapabilityManager.ts +++ b/packages/cli/src/ui/utils/terminalCapabilityManager.ts @@ -284,31 +284,43 @@ export class TerminalCapabilityManager { ); } - supportsOsc9Notifications(env: NodeJS.ProcessEnv = process.env): boolean { - if (env['WT_SESSION']) { - return false; - } + isTmux(env: NodeJS.ProcessEnv = process.env): boolean { + return !!env['TMUX']; + } - return ( - this.hasOsc9TerminalSignature(this.getTerminalName()) || - this.hasOsc9TerminalSignature(env['TERM_PROGRAM']) || - this.hasOsc9TerminalSignature(env['TERM']) + isScreen(env: NodeJS.ProcessEnv = process.env): boolean { + return !!env['STY']; + } + + isITerm2(env: NodeJS.ProcessEnv = process.env): boolean { + return !!( + this.getTerminalName()?.toLowerCase().includes('iterm') || + env['TERM_PROGRAM']?.toLowerCase().includes('iterm') ); } - private hasOsc9TerminalSignature(value: string | undefined): boolean { - if (!value) { - return false; - } - - const normalized = value.toLowerCase(); - return ( - normalized.includes('wezterm') || - normalized.includes('ghostty') || - normalized.includes('iterm') || - normalized.includes('kitty') + isAlacritty(env: NodeJS.ProcessEnv = process.env): boolean { + return !!( + this.getTerminalName()?.toLowerCase().includes('alacritty') || + env['ALACRITTY_WINDOW_ID'] || + env['TERM']?.toLowerCase().includes('alacritty') ); } + + isAppleTerminal(env: NodeJS.ProcessEnv = process.env): boolean { + return !!( + this.getTerminalName()?.toLowerCase().includes('apple_terminal') || + env['TERM_PROGRAM']?.toLowerCase().includes('apple_terminal') + ); + } + + isVSCodeTerminal(env: NodeJS.ProcessEnv = process.env): boolean { + return !!env['TERM_PROGRAM']?.toLowerCase().includes('vscode'); + } + + isWindowsTerminal(env: NodeJS.ProcessEnv = process.env): boolean { + return !!env['WT_SESSION']; + } } export const terminalCapabilityManager = diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts index ef972a4a0b..1dda7b355a 100644 --- a/packages/cli/src/utils/sandbox.test.ts +++ b/packages/cli/src/utils/sandbox.test.ts @@ -8,8 +8,13 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { spawn, exec, execFile, execSync } from 'node:child_process'; import os from 'node:os'; import fs from 'node:fs'; +import path from 'node:path'; import { start_sandbox } from './sandbox.js'; -import { FatalSandboxError, type SandboxConfig } from '@google/gemini-cli-core'; +import { + FatalSandboxError, + homedir, + type SandboxConfig, +} from '@google/gemini-cli-core'; import { createMockSandboxConfig } from '@google/gemini-cli-test-utils'; import { EventEmitter } from 'node:events'; @@ -133,6 +138,7 @@ describe('sandbox', () => { afterEach(() => { process.env = originalEnv; process.argv = originalArgv; + vi.unstubAllEnvs(); }); describe('start_sandbox', () => { @@ -171,6 +177,105 @@ describe('sandbox', () => { ); }); + it('should resolve custom seatbelt profile from user home directory', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.stubEnv('SEATBELT_PROFILE', 'custom-test'); + vi.mocked(fs.existsSync).mockImplementation((p) => + String(p).includes( + path.join(homedir(), '.gemini', 'sandbox-macos-custom-test.sb'), + ), + ); + const config: SandboxConfig = createMockSandboxConfig({ + command: 'sandbox-exec', + image: 'some-image', + }); + + interface MockProcess extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; + } + const mockSpawnProcess = new EventEmitter() as MockProcess; + mockSpawnProcess.stdout = new EventEmitter(); + mockSpawnProcess.stderr = new EventEmitter(); + vi.mocked(spawn).mockReturnValue( + mockSpawnProcess as unknown as ReturnType, + ); + + const promise = start_sandbox(config, [], undefined, ['arg1']); + + setTimeout(() => { + mockSpawnProcess.emit('close', 0); + }, 10); + + await expect(promise).resolves.toBe(0); + expect(spawn).toHaveBeenCalledWith( + 'sandbox-exec', + expect.any(Array), + expect.objectContaining({ stdio: 'inherit' }), + ); + const spawnArgs = vi.mocked(spawn).mock.calls[0]?.[1]; + expect(spawnArgs).toEqual( + expect.arrayContaining(['-f', expect.any(String)]), + ); + const profileArg = spawnArgs?.[spawnArgs.indexOf('-f') + 1]; + expect(profileArg).toEqual( + expect.stringContaining( + path.join(homedir(), '.gemini', 'sandbox-macos-custom-test.sb'), + ), + ); + }); + + it('should fall back to project .gemini directory when user profile is missing', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.stubEnv('SEATBELT_PROFILE', 'custom-test'); + vi.mocked(fs.existsSync).mockImplementation((p) => { + const s = String(p); + return ( + s.includes(path.join('.gemini', 'sandbox-macos-custom-test.sb')) && + !s.includes(path.join(homedir(), '.gemini')) + ); + }); + const config: SandboxConfig = createMockSandboxConfig({ + command: 'sandbox-exec', + image: 'some-image', + }); + + interface MockProcess extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; + } + const mockSpawnProcess = new EventEmitter() as MockProcess; + mockSpawnProcess.stdout = new EventEmitter(); + mockSpawnProcess.stderr = new EventEmitter(); + vi.mocked(spawn).mockReturnValue( + mockSpawnProcess as unknown as ReturnType, + ); + + const promise = start_sandbox(config, [], undefined, ['arg1']); + + setTimeout(() => { + mockSpawnProcess.emit('close', 0); + }, 10); + + await expect(promise).resolves.toBe(0); + expect(spawn).toHaveBeenCalledWith( + 'sandbox-exec', + expect.any(Array), + expect.objectContaining({ stdio: 'inherit' }), + ); + const spawnArgs = vi.mocked(spawn).mock.calls[0]?.[1]; + expect(spawnArgs).toEqual( + expect.arrayContaining(['-f', expect.any(String)]), + ); + const profileArg = spawnArgs?.[spawnArgs.indexOf('-f') + 1]; + expect(profileArg).toEqual( + expect.stringContaining( + path.join('.gemini', 'sandbox-macos-custom-test.sb'), + ), + ); + expect(profileArg).not.toContain(homedir()); + }); + it('should throw FatalSandboxError if seatbelt profile is missing', async () => { vi.mocked(os.platform).mockReturnValue('darwin'); vi.mocked(fs.existsSync).mockReturnValue(false); diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index dbd2ec64e3..6001725cdd 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -68,9 +68,17 @@ export async function start_sandbox( let profileFile = fileURLToPath( new URL(`sandbox-macos-${profile}.sb`, import.meta.url), ); - // if profile name is not recognized, then look for file under project settings directory + // if profile name is not recognized, look in user-level ~/.gemini first, + // then fall back to project-level .gemini. path.basename() strips any + // directory separators to prevent path traversal via SEATBELT_PROFILE. if (!BUILTIN_SEATBELT_PROFILES.includes(profile)) { - profileFile = path.join(GEMINI_DIR, `sandbox-macos-${profile}.sb`); + const safeProfile = path.basename(profile); + const fileName = `sandbox-macos-${safeProfile}.sb`; + const userProfileFile = path.join(homedir(), GEMINI_DIR, fileName); + const projectProfileFile = path.join(GEMINI_DIR, fileName); + profileFile = fs.existsSync(userProfileFile) + ? userProfileFile + : projectProfileFile; } if (!fs.existsSync(profileFile)) { throw new FatalSandboxError( diff --git a/packages/cli/src/utils/terminalNotifications.test.ts b/packages/cli/src/utils/terminalNotifications.test.ts index f05e650325..4abd3dddc4 100644 --- a/packages/cli/src/utils/terminalNotifications.test.ts +++ b/packages/cli/src/utils/terminalNotifications.test.ts @@ -11,6 +11,7 @@ import { MAX_NOTIFICATION_SUBTITLE_CHARS, MAX_NOTIFICATION_TITLE_CHARS, notifyViaTerminal, + TerminalNotificationMethod, } from './terminalNotifications.js'; const writeToStdout = vi.hoisted(() => vi.fn()); @@ -24,38 +25,19 @@ vi.mock('@google/gemini-cli-core', () => ({ })); describe('terminal notifications', () => { - const originalPlatform = process.platform; - beforeEach(() => { vi.resetAllMocks(); vi.unstubAllEnvs(); - Object.defineProperty(process, 'platform', { - value: 'darwin', - configurable: true, - }); + vi.stubEnv('TMUX', ''); + vi.stubEnv('STY', ''); + vi.stubEnv('WT_SESSION', ''); + vi.stubEnv('TERM_PROGRAM', ''); + vi.stubEnv('TERM', ''); + vi.stubEnv('ALACRITTY_WINDOW_ID', ''); }); afterEach(() => { vi.unstubAllEnvs(); - Object.defineProperty(process, 'platform', { - value: originalPlatform, - configurable: true, - }); - }); - - it('emits notification on non-macOS platforms', async () => { - Object.defineProperty(process, 'platform', { - value: 'linux', - configurable: true, - }); - - const shown = await notifyViaTerminal(true, { - title: 't', - body: 'b', - }); - - expect(shown).toBe(true); - expect(writeToStdout).toHaveBeenCalled(); }); it('returns false without writing when disabled', async () => { @@ -68,8 +50,7 @@ describe('terminal notifications', () => { expect(writeToStdout).not.toHaveBeenCalled(); }); - it('emits OSC 9 notification when supported terminal is detected', async () => { - vi.stubEnv('WT_SESSION', ''); + it('emits OSC 9 notification when iTerm2 is detected', async () => { vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); const shown = await notifyViaTerminal(true, { @@ -85,23 +66,57 @@ describe('terminal notifications', () => { expect(emitted.endsWith('\x07')).toBe(true); }); - it('emits BEL fallback when OSC 9 is not supported', async () => { - vi.stubEnv('TERM_PROGRAM', ''); - vi.stubEnv('TERM', ''); - + it('emits OSC 777 for unknown terminals', async () => { const shown = await notifyViaTerminal(true, { title: 'Title', subtitle: 'Subtitle', body: 'Body', }); + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledTimes(1); + const emitted = String(writeToStdout.mock.calls[0][0]); + expect(emitted.startsWith('\x1b]777;notify;')).toBe(true); + }); + + it('uses BEL when Windows Terminal is detected', async () => { + vi.stubEnv('WT_SESSION', '1'); + + const shown = await notifyViaTerminal(true, { + title: 'Title', + body: 'Body', + }); + expect(shown).toBe(true); expect(writeToStdout).toHaveBeenCalledWith('\x07'); }); - it('uses BEL fallback when WT_SESSION is set', async () => { - vi.stubEnv('WT_SESSION', '1'); - vi.stubEnv('TERM_PROGRAM', 'WezTerm'); + it('uses BEL when Alacritty is detected', async () => { + vi.stubEnv('ALACRITTY_WINDOW_ID', '1'); + + const shown = await notifyViaTerminal(true, { + title: 'Title', + body: 'Body', + }); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledWith('\x07'); + }); + + it('uses BEL when Apple Terminal is detected', async () => { + vi.stubEnv('TERM_PROGRAM', 'Apple_Terminal'); + + const shown = await notifyViaTerminal(true, { + title: 'Title', + body: 'Body', + }); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledWith('\x07'); + }); + + it('uses BEL when VSCode Terminal is detected', async () => { + vi.stubEnv('TERM_PROGRAM', 'vscode'); const shown = await notifyViaTerminal(true, { title: 'Title', @@ -127,7 +142,6 @@ describe('terminal notifications', () => { }); it('strips terminal control sequences and newlines from payload text', async () => { - vi.stubEnv('WT_SESSION', ''); vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); const shown = await notifyViaTerminal(true, { @@ -162,4 +176,124 @@ describe('terminal notifications', () => { MAX_NOTIFICATION_BODY_CHARS, ); }); + + it('emits OSC 9 notification when method is explicitly set to osc9', async () => { + // Explicitly set terminal to something that would normally use BEL + vi.stubEnv('WT_SESSION', '1'); + + const shown = await notifyViaTerminal( + true, + { + title: 'Explicit OSC 9', + body: 'Body', + }, + TerminalNotificationMethod.Osc9, + ); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledTimes(1); + const emitted = String(writeToStdout.mock.calls[0][0]); + expect(emitted.startsWith('\x1b]9;')).toBe(true); + expect(emitted.endsWith('\x07')).toBe(true); + expect(emitted).toContain('Explicit OSC 9'); + }); + + it('emits OSC 777 notification when method is explicitly set to osc777', async () => { + // Explicitly set terminal to something that would normally use BEL + vi.stubEnv('WT_SESSION', '1'); + const shown = await notifyViaTerminal( + true, + { + title: 'Explicit OSC 777', + body: 'Body', + }, + TerminalNotificationMethod.Osc777, + ); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledTimes(1); + const emitted = String(writeToStdout.mock.calls[0][0]); + expect(emitted.startsWith('\x1b]777;notify;')).toBe(true); + expect(emitted.endsWith('\x07')).toBe(true); + expect(emitted).toContain('Explicit OSC 777'); + }); + + it('emits BEL notification when method is explicitly set to bell', async () => { + // Explicitly set terminal to something that supports OSC 9 + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); + + const shown = await notifyViaTerminal( + true, + { + title: 'Explicit BEL', + body: 'Body', + }, + TerminalNotificationMethod.Bell, + ); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledTimes(1); + expect(writeToStdout).toHaveBeenCalledWith('\x07'); + }); + + it('replaces semicolons with colons in OSC 777 to avoid breaking the sequence', async () => { + const shown = await notifyViaTerminal( + true, + { + title: 'Title; with; semicolons', + subtitle: 'Sub;title', + body: 'Body; with; semicolons', + }, + TerminalNotificationMethod.Osc777, + ); + + expect(shown).toBe(true); + const emitted = String(writeToStdout.mock.calls[0][0]); + + // Format: \x1b]777;notify;title;body\x07 + expect(emitted).toContain('Title: with: semicolons'); + expect(emitted).toContain('Sub:title'); + expect(emitted).toContain('Body: with: semicolons'); + expect(emitted).not.toContain('Title; with; semicolons'); + expect(emitted).not.toContain('Body; with; semicolons'); + + // Extract everything after '\x1b]777;notify;' and before '\x07' + const payload = emitted.slice('\x1b]777;notify;'.length, -1); + + // There should be exactly one semicolon separating title and body + const semicolonsCount = (payload.match(/;/g) || []).length; + expect(semicolonsCount).toBe(1); + }); + + it('wraps OSC sequence in tmux passthrough when TMUX env var is set', async () => { + vi.stubEnv('TMUX', '1'); + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); + + const shown = await notifyViaTerminal(true, { + title: 'Title', + body: 'Body', + }); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledTimes(1); + const emitted = String(writeToStdout.mock.calls[0][0]); + expect(emitted.startsWith('\x1bPtmux;\x1b\x1b]9;')).toBe(true); + expect(emitted.endsWith('\x1b\\')).toBe(true); + }); + + it('wraps OSC sequence in GNU screen passthrough when STY env var is set', async () => { + vi.stubEnv('STY', '1'); + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); + + const shown = await notifyViaTerminal(true, { + title: 'Title', + body: 'Body', + }); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledTimes(1); + const emitted = String(writeToStdout.mock.calls[0][0]); + expect(emitted.startsWith('\x1bP\x1b]9;')).toBe(true); + expect(emitted.endsWith('\x1b\\')).toBe(true); + }); }); diff --git a/packages/cli/src/utils/terminalNotifications.ts b/packages/cli/src/utils/terminalNotifications.ts index c0ad259a4b..ea2539ff0f 100644 --- a/packages/cli/src/utils/terminalNotifications.ts +++ b/packages/cli/src/utils/terminalNotifications.ts @@ -15,12 +15,8 @@ export const MAX_NOTIFICATION_BODY_CHARS = 180; const BEL = '\x07'; const OSC9_PREFIX = '\x1b]9;'; -const OSC9_SEPARATOR = ' | '; -const MAX_OSC9_MESSAGE_CHARS = - MAX_NOTIFICATION_TITLE_CHARS + - MAX_NOTIFICATION_SUBTITLE_CHARS + - MAX_NOTIFICATION_BODY_CHARS + - OSC9_SEPARATOR.length * 2; +const OSC777_PREFIX = '\x1b]777;notify;'; +const OSC_TEXT_SEPARATOR = ' | '; export interface RunEventNotificationContent { title: string; @@ -81,36 +77,100 @@ export function isNotificationsEnabled(settings: LoadedSettings): boolean { return general?.enableNotifications === true; } -function buildTerminalNotificationMessage( - content: RunEventNotificationContent, -): string { - const pieces = [content.title, content.subtitle, content.body].filter( - Boolean, - ); - const combined = pieces.join(OSC9_SEPARATOR); - return sanitizeForDisplay(combined, MAX_OSC9_MESSAGE_CHARS); +export enum TerminalNotificationMethod { + Auto = 'auto', + Osc9 = 'osc9', + Osc777 = 'osc777', + Bell = 'bell', +} + +export function getNotificationMethod( + settings: LoadedSettings, +): TerminalNotificationMethod { + switch (settings.merged.general?.notificationMethod) { + case TerminalNotificationMethod.Osc9: + return TerminalNotificationMethod.Osc9; + case TerminalNotificationMethod.Osc777: + return TerminalNotificationMethod.Osc777; + case TerminalNotificationMethod.Bell: + return TerminalNotificationMethod.Bell; + default: + return TerminalNotificationMethod.Auto; + } +} + +function wrapWithPassthrough(sequence: string): string { + const capabilityManager = TerminalCapabilityManager.getInstance(); + if (capabilityManager.isTmux()) { + // eslint-disable-next-line no-control-regex + return `\x1bPtmux;${sequence.replace(/\x1b/g, '\x1b\x1b')}\x1b\\`; + } else if (capabilityManager.isScreen()) { + return `\x1bP${sequence}\x1b\\`; + } + return sequence; } function emitOsc9Notification(content: RunEventNotificationContent): void { - const message = buildTerminalNotificationMessage(content); - if (!TerminalCapabilityManager.getInstance().supportsOsc9Notifications()) { - writeToStdout(BEL); - return; - } + const sanitized = sanitizeNotificationContent(content); + const pieces = [sanitized.title, sanitized.subtitle, sanitized.body].filter( + Boolean, + ); + const combined = pieces.join(OSC_TEXT_SEPARATOR); - writeToStdout(`${OSC9_PREFIX}${message}${BEL}`); + writeToStdout(wrapWithPassthrough(`${OSC9_PREFIX}${combined}${BEL}`)); +} + +function emitOsc777Notification(content: RunEventNotificationContent): void { + const sanitized = sanitizeNotificationContent(content); + const bodyParts = [sanitized.subtitle, sanitized.body].filter(Boolean); + const body = bodyParts.join(OSC_TEXT_SEPARATOR); + + // Replace ';' with ':' to avoid breaking the OSC 777 sequence + const safeTitle = sanitized.title.replace(/;/g, ':'); + const safeBody = body.replace(/;/g, ':'); + + writeToStdout( + wrapWithPassthrough(`${OSC777_PREFIX}${safeTitle};${safeBody}${BEL}`), + ); +} + +function emitBellNotification(): void { + writeToStdout(BEL); } export async function notifyViaTerminal( notificationsEnabled: boolean, content: RunEventNotificationContent, + method: TerminalNotificationMethod = TerminalNotificationMethod.Auto, ): Promise { if (!notificationsEnabled) { return false; } try { - emitOsc9Notification(sanitizeNotificationContent(content)); + if (method === TerminalNotificationMethod.Osc9) { + emitOsc9Notification(content); + } else if (method === TerminalNotificationMethod.Osc777) { + emitOsc777Notification(content); + } else if (method === TerminalNotificationMethod.Bell) { + emitBellNotification(); + } else { + // auto + const capabilityManager = TerminalCapabilityManager.getInstance(); + if (capabilityManager.isITerm2()) { + emitOsc9Notification(content); + } else if ( + capabilityManager.isAlacritty() || + capabilityManager.isAppleTerminal() || + capabilityManager.isVSCodeTerminal() || + capabilityManager.isWindowsTerminal() + ) { + emitBellNotification(); + } else { + emitOsc777Notification(content); + } + } + return true; } catch (error) { debugLogger.debug('Failed to emit terminal notification:', error); diff --git a/packages/core/package.json b/packages/core/package.json index c958ad1315..615fa68e3c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-core", - "version": "0.39.0-nightly.20260408.e77b22e63", + "version": "0.40.0-nightly.20260414.g5b1f7375a", "description": "Gemini CLI Core", "license": "Apache-2.0", "repository": { @@ -20,7 +20,8 @@ "typecheck": "tsgo --noEmit" }, "files": [ - "dist" + "dist", + "vendor" ], "dependencies": { "@a2a-js/sdk": "0.3.11", @@ -31,7 +32,6 @@ "@google/genai": "1.30.0", "@grpc/grpc-js": "^1.14.3", "@iarna/toml": "^2.2.5", - "@joshua.litt/get-ripgrep": "^0.0.3", "@modelcontextprotocol/sdk": "^1.23.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.211.0", @@ -59,6 +59,7 @@ "diff": "^8.0.3", "dotenv": "^17.2.4", "dotenv-expand": "^12.0.3", + "execa": "^9.6.1", "fast-levenshtein": "^2.0.6", "fdir": "^6.4.6", "fzf": "^0.5.2", diff --git a/packages/core/src/agents/skill-extraction-agent.test.ts b/packages/core/src/agents/skill-extraction-agent.test.ts new file mode 100644 index 0000000000..a67c7db270 --- /dev/null +++ b/packages/core/src/agents/skill-extraction-agent.test.ts @@ -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.', + ); + }); +}); diff --git a/packages/core/src/agents/skill-extraction-agent.ts b/packages/core/src/agents/skill-extraction-agent.ts index 2678bd206d..4aa18af388 100644 --- a/packages/core/src/agents/skill-extraction-agent.ts +++ b/packages/core/src/agents/skill-extraction-agent.ts @@ -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.md # Required entrypoint', - ' scripts/.* # Optional helper scripts (Python stdlib-only or shell)', - '', - 'SKILL.md structure:', - '', - ' ---', - ' name: ', - ' description: <1-2 lines; include concrete triggers in user-like language>', - ' ---', - '', - ' ## When to Use', - ' ', - '', - ' ## Procedure', - ' ', - '', - ' ## Pitfalls and Fixes', - ' likely cause -> fix; only include observed failures>', - '', - ' ## Verification', - ' ', - '', - '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: { diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 3bc1e94f8d..ab000b2691 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -1774,6 +1774,95 @@ describe('Server Config (config.ts)', () => { expect(config1.topicState.getTopic()).toBe('Topic 1'); expect(config2.topicState.getTopic()).toBe('Topic 2'); }); + + it('updates storage session-scoped directories when the sessionId changes', async () => { + const config = new Config({ + ...baseParams, + sessionId: 'session-one', + plan: true, + }); + + await config.initialize(); + const tempDir = config.storage.getProjectTempDir(); + const oldPlansDir = path.join(tempDir, 'session-one', 'plans'); + const oldTrackerService = config.getTrackerService(); + + config.setSessionId('session-two'); + + expect(config.getSessionId()).toBe('session-two'); + expect(config.storage.getProjectTempPlansDir()).toBe( + path.join(tempDir, 'session-two', 'plans'), + ); + expect(config.storage.getProjectTempTrackerDir()).toBe( + path.join(tempDir, 'session-two', 'tracker'), + ); + expect(config.getTrackerService()).not.toBe(oldTrackerService); + expect(config.getTrackerService().trackerDir).toBe( + path.join(tempDir, 'session-two', 'tracker'), + ); + expect(config.getWorkspaceContext().getDirectories()).not.toContain( + oldPlansDir, + ); + }); + + it('does not throw when changing sessions before the previous plans dir exists', async () => { + const config = new Config({ + ...baseParams, + sessionId: 'session-one', + plan: true, + }); + + await config.initialize(); + const missingPlansDir = config.storage.getProjectTempPlansDir(); + const realpathMock = vi.mocked(fs.realpathSync); + const originalImplementation = realpathMock.getMockImplementation(); + + try { + realpathMock.mockImplementation((input) => { + const normalizedInput = + typeof input === 'string' || Buffer.isBuffer(input) + ? input + : input.toString(); + + if (normalizedInput === missingPlansDir) { + const error = new Error( + `ENOENT: no such file or directory, ${normalizedInput}`, + ); + Object.assign(error, { code: 'ENOENT' }); + throw error; + } + if (originalImplementation) { + return originalImplementation(input); + } + return normalizedInput; + }); + + expect(() => config.setSessionId('session-two')).not.toThrow(); + } finally { + realpathMock.mockImplementation((input) => { + if (originalImplementation) { + return originalImplementation(input); + } + return typeof input === 'string' || Buffer.isBuffer(input) + ? input + : input.toString(); + }); + } + }); + + it('clears the approved plan when starting a new session', () => { + const config = new Config({ + ...baseParams, + sessionId: 'session-one', + }); + + config.setApprovedPlanPath('/tmp/session-one/plans/approved.md'); + + expect(() => config.resetNewSessionState('session-two')).not.toThrow(); + + expect(config.getSessionId()).toBe('session-two'); + expect(config.getApprovedPlanPath()).toBeUndefined(); + }); }); describe('GemmaModelRouterSettings', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index c19cc257c3..9dbf0f8115 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -30,6 +30,8 @@ import { ResourceRegistry } from '../resources/resource-registry.js'; import { ToolRegistry } from '../tools/tool-registry.js'; import { LSTool } from '../tools/ls.js'; import { ReadFileTool } from '../tools/read-file.js'; +import { ReadMcpResourceTool } from '../tools/read-mcp-resource.js'; +import { ListMcpResourcesTool } from '../tools/list-mcp-resources.js'; import { GrepTool } from '../tools/grep.js'; import { canUseRipgrep, RipGrepTool } from '../tools/ripGrep.js'; import { GlobTool } from '../tools/glob.js'; @@ -1760,7 +1762,22 @@ export class Config implements McpContext, AgentLoopContext { } setSessionId(sessionId: string): void { + const previousPlansDir = this.storage.isInitialized() + ? this.storage.getPlansDir() + : undefined; + this._sessionId = sessionId; + this.storage.setSessionId(sessionId); + this.trackerService = undefined; + + if (previousPlansDir) { + this.refreshSessionScopedPlansDirectory(previousPlansDir); + } + } + + resetNewSessionState(sessionId: string): void { + this.setSessionId(sessionId); + this.approvedPlanPath = undefined; } setTerminalBackground(terminalBackground: string | undefined): void { @@ -2049,6 +2066,37 @@ export class Config implements McpContext, AgentLoopContext { return getWorkspaceContextOverride() ?? this.workspaceContext; } + private refreshSessionScopedPlansDirectory(previousPlansDir: string): void { + const nextPlansDir = this.storage.getPlansDir(); + if (previousPlansDir === nextPlansDir) { + return; + } + + const pathsToRemove = new Set([previousPlansDir]); + try { + pathsToRemove.add(resolveToRealPath(previousPlansDir)); + } catch { + // The previous session's plans directory may never have been created. + // In that case there is nothing to resolve or remove beyond the raw path. + } + + const currentDirectories = this.workspaceContext + .getDirectories() + .filter((dir) => !pathsToRemove.has(dir)); + + this.workspaceContext.setDirectories(currentDirectories); + + try { + if (fs.existsSync(nextPlansDir)) { + this.workspaceContext.addDirectory(nextPlansDir); + } + } catch { + // Ignore invalid or unreadable plans directories here. This mirrors + // initialization behavior, which only adds the plans directory when it + // already exists and is readable. + } + } + getAgentRegistry(): AgentRegistry { return this.agentRegistry; } @@ -3552,6 +3600,7 @@ export class Config implements McpContext, AgentLoopContext { registry.registerTool(new RipGrepTool(this, this.messageBus)), ); } else { + debugLogger.warn(`Ripgrep is not available. Falling back to GrepTool.`); logRipgrepFallback(this, new RipgrepFallbackEvent(errorString)); maybeRegister(GrepTool, () => registry.registerTool(new GrepTool(this, this.messageBus)), @@ -3578,6 +3627,12 @@ export class Config implements McpContext, AgentLoopContext { maybeRegister(WebFetchTool, () => registry.registerTool(new WebFetchTool(this, this.messageBus)), ); + maybeRegister(ReadMcpResourceTool, () => + registry.registerTool(new ReadMcpResourceTool(this, this.messageBus)), + ); + maybeRegister(ListMcpResourcesTool, () => + registry.registerTool(new ListMcpResourcesTool(this, this.messageBus)), + ); maybeRegister(ShellTool, () => registry.registerTool(new ShellTool(this, this.messageBus)), ); diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index 822e1c70be..6b73e0105e 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -211,6 +211,27 @@ describe('Storage – additional helpers', () => { expect(storageWithSession.getProjectTempTrackerDir()).toBe(expected); }); + it('updates session-scoped directories when the sessionId changes', async () => { + const storageWithSession = new Storage(projectRoot, 'session-one'); + ProjectRegistry.prototype.getShortId = vi + .fn() + .mockReturnValue(PROJECT_SLUG); + await storageWithSession.initialize(); + const tempDir = storageWithSession.getProjectTempDir(); + + storageWithSession.setSessionId('session-two'); + + expect(storageWithSession.getProjectTempPlansDir()).toBe( + path.join(tempDir, 'session-two', 'plans'), + ); + expect(storageWithSession.getProjectTempTrackerDir()).toBe( + path.join(tempDir, 'session-two', 'tracker'), + ); + expect(storageWithSession.getProjectTempTasksDir()).toBe( + path.join(tempDir, 'session-two', 'tasks'), + ); + }); + describe('Session and JSON Loading', () => { beforeEach(async () => { await storage.initialize(); diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index d49e027369..5e3aada4e5 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -28,7 +28,7 @@ export const AUTO_SAVED_POLICY_FILENAME = 'auto-saved.toml'; export class Storage { private readonly targetDir: string; - private readonly sessionId: string | undefined; + private sessionId: string | undefined; private projectIdentifier: string | undefined; private initPromise: Promise | undefined; private customPlansDir: string | undefined; @@ -42,6 +42,14 @@ export class Storage { this.customPlansDir = dir; } + setSessionId(sessionId: string | undefined): void { + this.sessionId = sessionId; + } + + isInitialized(): boolean { + return !!this.projectIdentifier; + } + static getGlobalGeminiDir(): string { const homeDir = homedir(); if (!homeDir) { diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts index 35d7879f96..bf7eef167d 100644 --- a/packages/core/src/core/contentGenerator.test.ts +++ b/packages/core/src/core/contentGenerator.test.ts @@ -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', () => { diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 4fc56b59b4..31e36ede41 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -101,6 +101,21 @@ export type ContentGeneratorConfig = { customHeaders?: Record; }; +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; } = { 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 }), }); diff --git a/packages/core/src/core/geminiChat_network_retry.test.ts b/packages/core/src/core/geminiChat_network_retry.test.ts index 4683e29261..83d5848e75 100644 --- a/packages/core/src/core/geminiChat_network_retry.test.ts +++ b/packages/core/src/core/geminiChat_network_retry.test.ts @@ -519,4 +519,70 @@ describe('GeminiChat Network Retries', () => { }), ); }); + + it('should retry on OpenSSL 3.x SSL error during stream iteration (ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC)', async () => { + // OpenSSL 3.x produces a different error code format than OpenSSL 1.x + const sslError = new Error( + 'request to https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent failed', + ) as NodeJS.ErrnoException & { type?: string }; + sslError.type = 'system'; + sslError.errno = + 'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC' as unknown as number; + sslError.code = 'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC'; + + vi.mocked(mockContentGenerator.generateContentStream) + .mockImplementationOnce(async () => + (async function* () { + yield { + candidates: [ + { content: { parts: [{ text: 'Partial response...' }] } }, + ], + } as unknown as GenerateContentResponse; + throw sslError; + })(), + ) + .mockImplementationOnce(async () => + (async function* () { + yield { + candidates: [ + { + content: { parts: [{ text: 'Complete response after retry' }] }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(), + ); + + const stream = await chat.sendMessageStream( + { model: 'test-model' }, + 'test message', + 'prompt-id-ssl3-mid-stream', + new AbortController().signal, + LlmRole.MAIN, + ); + + const events: StreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + const retryEvent = events.find((e) => e.type === StreamEventType.RETRY); + expect(retryEvent).toBeDefined(); + + const successChunk = events.find( + (e) => + e.type === StreamEventType.CHUNK && + e.value.candidates?.[0]?.content?.parts?.[0]?.text === + 'Complete response after retry', + ); + expect(successChunk).toBeDefined(); + + expect(mockLogNetworkRetryAttempt).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + error_type: 'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC', + }), + ); + }); }); diff --git a/packages/core/src/policy/policies/read-only.toml b/packages/core/src/policy/policies/read-only.toml index 0a8b465fe8..3d010956fc 100644 --- a/packages/core/src/policy/policies/read-only.toml +++ b/packages/core/src/policy/policies/read-only.toml @@ -47,7 +47,10 @@ toolName = [ # Topic grouping tool is innocuous and used for UI organization. "update_topic", # Core agent lifecycle tool - "complete_task" + "complete_task", + # MCP resource tools + "read_mcp_resource", + "list_mcp_resources" ] decision = "allow" priority = 50 diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts index b6c11a079b..5606c49793 100644 --- a/packages/core/src/policy/policy-engine.test.ts +++ b/packages/core/src/policy/policy-engine.test.ts @@ -1762,6 +1762,39 @@ describe('PolicyEngine', () => { }); describe('shell command parsing failure', () => { + it('should return ALLOW in YOLO mode for dangerous commands due to heuristics override', async () => { + // Create an engine with YOLO mode and a sandbox manager that flags a command as dangerous + const rules: PolicyRule[] = [ + { + toolName: '*', + decision: PolicyDecision.ALLOW, + priority: 999, + modes: [ApprovalMode.YOLO], + }, + ]; + + const mockSandboxManager = new NoopSandboxManager(); + mockSandboxManager.isDangerousCommand = vi.fn().mockReturnValue(true); + mockSandboxManager.isKnownSafeCommand = vi.fn().mockReturnValue(false); + + engine = new PolicyEngine({ + rules, + approvalMode: ApprovalMode.YOLO, + sandboxManager: mockSandboxManager, + }); + + const result = await engine.check( + { + name: 'run_shell_command', + args: { command: 'powershell echo "dangerous"' }, + }, + undefined, + ); + + // Even though the command is flagged as dangerous, YOLO mode should preserve the ALLOW decision + expect(result.decision).toBe(PolicyDecision.ALLOW); + }); + it('should return ALLOW in YOLO mode even if shell command parsing fails', async () => { const { splitCommands } = await import('../utils/shell-utils.js'); const rules: PolicyRule[] = [ diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts index eb5b141ba5..a9e049c74d 100644 --- a/packages/core/src/policy/policy-engine.ts +++ b/packages/core/src/policy/policy-engine.ts @@ -312,6 +312,13 @@ export class PolicyEngine { const parsedArgs = parsedObjArgs.map(extractStringFromParseEntry); if (this.sandboxManager.isDangerousCommand(parsedArgs)) { + if (this.approvalMode === ApprovalMode.YOLO) { + debugLogger.debug( + `[PolicyEngine.check] Command evaluated as dangerous, but YOLO mode is active. Preserving decision: ${command}`, + ); + return decision; + } + debugLogger.debug( `[PolicyEngine.check] Command evaluated as dangerous, forcing ASK_USER: ${command}`, ); diff --git a/packages/core/src/policy/toml-loader.test.ts b/packages/core/src/policy/toml-loader.test.ts index 1d3c4e0eb6..494f5a9bb5 100644 --- a/packages/core/src/policy/toml-loader.test.ts +++ b/packages/core/src/policy/toml-loader.test.ts @@ -1057,6 +1057,25 @@ priority = 100 cliHelpResult.decision, 'cli_help should be ALLOWED in Plan Mode', ).toBe(PolicyDecision.ALLOW); + + // 7. Verify MCP resource tools are ALLOWED + const listMcpResult = await engine.check( + { name: 'list_mcp_resources' }, + undefined, + ); + expect( + listMcpResult.decision, + 'list_mcp_resources should be ALLOWED in Plan Mode', + ).toBe(PolicyDecision.ALLOW); + + const readMcpResult = await engine.check( + { name: 'read_mcp_resource', args: { uri: 'test://resource' } }, + undefined, + ); + expect( + readMcpResult.decision, + 'read_mcp_resource should be ALLOWED in Plan Mode', + ).toBe(PolicyDecision.ALLOW); } finally { await fs.rm(tempPolicyDir, { recursive: true, force: true }); } diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts index a431085c33..27c3e42746 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts @@ -25,7 +25,6 @@ import { import { isStrictlyApproved, verifySandboxOverrides, - getCommandName, } from '../utils/commandUtils.js'; import { assertValidPathString } from '../../utils/paths.js'; import { @@ -40,6 +39,11 @@ import { import { isErrnoException } from '../utils/fsUtils.js'; import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js'; import { buildBwrapArgs } from './bwrapArgsBuilder.js'; +import { + getCommandRoots, + initializeShellParsers, + stripShellWrapper, +} from '../../utils/shell-utils.js'; let cachedBpfPath: string | undefined; @@ -218,7 +222,15 @@ export class LinuxSandboxManager implements SandboxManager { args = ['-c', 'cat > "$1"', '_', ...args]; } - const commandName = await getCommandName({ ...req, command, args }); + await initializeShellParsers(); + const fullCmd = [command, ...args].join(' '); + const stripped = stripShellWrapper(fullCmd); + const roots = getCommandRoots(stripped).filter( + (r) => r !== 'shopt' && r !== 'set', + ); + const commandName = roots.length > 0 ? roots[0] : join(command); + const isGitCommand = roots.includes('git'); + const isApproved = allowOverrides ? await isStrictlyApproved( { ...req, command, args }, @@ -253,6 +265,15 @@ export class LinuxSandboxManager implements SandboxManager { false, }; + // If the workspace is writable and we're running a git command, + // automatically allow write access to the .git directory. + if (workspaceWrite && isGitCommand) { + const gitDir = join(this.options.workspace, '.git'); + if (!mergedAdditional.fileSystem!.write!.includes(gitDir)) { + mergedAdditional.fileSystem!.write!.push(gitDir); + } + } + const { command: finalCommand, args: finalArgs } = handleReadWriteCommands( req, mergedAdditional, diff --git a/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts b/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts index 6cff168d21..45571f066f 100644 --- a/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts +++ b/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts @@ -115,14 +115,14 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => { workspace, workspace, '--ro-bind', + `${workspace}/.git`, + `${workspace}/.git`, + '--ro-bind', `${workspace}/.gitignore`, `${workspace}/.gitignore`, '--ro-bind', `${workspace}/.geminiignore`, `${workspace}/.geminiignore`, - '--ro-bind', - `${workspace}/.git`, - `${workspace}/.git`, ]); }); diff --git a/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts b/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts index 591bba8a0e..14301bb888 100644 --- a/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts +++ b/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts @@ -14,6 +14,7 @@ import { import { isErrnoException } from '../utils/fsUtils.js'; import { spawnAsync } from '../../utils/shell-utils.js'; import { debugLogger } from '../../utils/debugLogger.js'; +import { toPathKey } from '../../utils/paths.js'; /** * Options for building bubblewrap (bwrap) arguments. @@ -63,58 +64,101 @@ export async function buildBwrapArgs( '/tmp', ); - const bindFlag = workspaceWrite ? '--bind-try' : '--ro-bind-try'; + type MountType = + | '--bind' + | '--ro-bind' + | '--bind-try' + | '--ro-bind-try' + | '--symlink'; - bwrapArgs.push(bindFlag, workspace.original, workspace.original); + type Mount = + | { + type: MountType; + src: string; + dest: string; + } + | { type: '--tmpfs-ro'; dest: string }; + + const mounts: Mount[] = []; + + const bindFlag: MountType = workspaceWrite ? '--bind-try' : '--ro-bind-try'; + mounts.push({ + type: bindFlag, + src: workspace.original, + dest: workspace.original, + }); if (workspace.resolved !== workspace.original) { - bwrapArgs.push(bindFlag, workspace.resolved, workspace.resolved); + mounts.push({ + type: bindFlag, + src: workspace.resolved, + dest: workspace.resolved, + }); } for (const includeDir of resolvedPaths.globalIncludes) { - bwrapArgs.push('--ro-bind-try', includeDir, includeDir); + mounts.push({ type: '--ro-bind-try', src: includeDir, dest: includeDir }); } for (const allowedPath of resolvedPaths.policyAllowed) { if (fs.existsSync(allowedPath)) { - bwrapArgs.push('--bind-try', allowedPath, allowedPath); + mounts.push({ type: '--bind-try', src: allowedPath, dest: allowedPath }); } else { - // If the path doesn't exist, we still want to allow access to its parent - // to enable creating it. const parent = dirname(allowedPath); - bwrapArgs.push( - isReadOnlyCommand ? '--ro-bind-try' : '--bind-try', - parent, - parent, - ); + mounts.push({ + type: isReadOnlyCommand ? '--ro-bind-try' : '--bind-try', + src: parent, + dest: parent, + }); } } for (const p of resolvedPaths.policyRead) { - bwrapArgs.push('--ro-bind-try', p, p); + mounts.push({ type: '--ro-bind-try', src: p, dest: p }); } + // Collect explicit additional write permissions. for (const p of resolvedPaths.policyWrite) { - bwrapArgs.push('--bind-try', p, p); + mounts.push({ type: '--bind-try', src: p, dest: p }); } + const policyWriteKeys = new Set(resolvedPaths.policyWrite.map(toPathKey)); + for (const file of GOVERNANCE_FILES) { const filePath = join(workspace.original, file.path); const realPath = join(workspace.resolved, file.path); - bwrapArgs.push('--ro-bind', filePath, filePath); - if (realPath !== filePath) { - bwrapArgs.push('--ro-bind', realPath, realPath); + + const isExplicitlyWritable = + policyWriteKeys.has(toPathKey(filePath)) || + policyWriteKeys.has(toPathKey(realPath)); + + // If the workspace is writable, we allow editing .gitignore and .geminiignore by default. + // .git remains protected unless explicitly requested (e.g. for git commands). + const isImplicitlyWritable = workspaceWrite && file.path !== '.git'; + + if (!isExplicitlyWritable && !isImplicitlyWritable) { + mounts.push({ type: '--ro-bind', src: filePath, dest: filePath }); + if (realPath !== filePath) { + mounts.push({ type: '--ro-bind', src: realPath, dest: realPath }); + } } } - // Grant read-only access to git worktrees/submodules. We do this last in order to - // ensure that these rules aren't overwritten by broader write policies. + // Grant read-only access to git worktrees/submodules. if (resolvedPaths.gitWorktree) { const { worktreeGitDir, mainGitDir } = resolvedPaths.gitWorktree; - if (worktreeGitDir) { - bwrapArgs.push('--ro-bind-try', worktreeGitDir, worktreeGitDir); + if (worktreeGitDir && !policyWriteKeys.has(toPathKey(worktreeGitDir))) { + mounts.push({ + type: '--ro-bind-try', + src: worktreeGitDir, + dest: worktreeGitDir, + }); } - if (mainGitDir) { - bwrapArgs.push('--ro-bind-try', mainGitDir, mainGitDir); + if (mainGitDir && !policyWriteKeys.has(toPathKey(mainGitDir))) { + mounts.push({ + type: '--ro-bind-try', + src: mainGitDir, + dest: mainGitDir, + }); } } @@ -123,37 +167,23 @@ export async function buildBwrapArgs( try { const stat = fs.statSync(p); if (stat.isDirectory()) { - bwrapArgs.push('--tmpfs', p, '--remount-ro', p); + mounts.push({ type: '--tmpfs-ro', dest: p }); } else { - bwrapArgs.push('--ro-bind', '/dev/null', p); + mounts.push({ type: '--ro-bind', src: '/dev/null', dest: p }); } } catch (e: unknown) { if (isErrnoException(e) && e.code === 'ENOENT') { - bwrapArgs.push('--symlink', '/dev/null', p); + mounts.push({ type: '--symlink', src: '/dev/null', dest: p }); } else { debugLogger.warn( `Failed to secure forbidden path ${p}: ${e instanceof Error ? e.message : String(e)}`, ); - bwrapArgs.push('--ro-bind', '/dev/null', p); + mounts.push({ type: '--ro-bind', src: '/dev/null', dest: p }); } } } // Mask secret files (.env, .env.*) - const secretArgs = await getSecretFilesArgs(resolvedPaths, maskFilePath); - bwrapArgs.push(...secretArgs); - - return bwrapArgs; -} - -/** - * Generates bubblewrap arguments to mask secret files. - */ -async function getSecretFilesArgs( - resolvedPaths: ResolvedSandboxPaths, - maskPath: string, -): Promise { - const args: string[] = []; const searchDirs = new Set([ resolvedPaths.workspace.original, resolvedPaths.workspace.resolved, @@ -164,9 +194,6 @@ async function getSecretFilesArgs( for (const dir of searchDirs) { try { - // Use the native 'find' command for performance and to catch nested secrets. - // We limit depth to 3 to keep it fast while covering common nested structures. - // We use -prune to skip heavy directories efficiently while matching dotfiles. const findResult = await spawnAsync('find', [ dir, '-maxdepth', @@ -203,7 +230,7 @@ async function getSecretFilesArgs( const files = findResult.stdout.toString().split('\0'); for (const file of files) { if (file.trim()) { - args.push('--bind', maskPath, file.trim()); + mounts.push({ type: '--bind', src: maskFilePath, dest: file.trim() }); } } } catch (e) { @@ -213,5 +240,19 @@ async function getSecretFilesArgs( ); } } - return args; + + // Sort mounts by destination path length to ensure parents are bound before children. + // This prevents hierarchical masking where a parent mount would hide a child mount. + mounts.sort((a, b) => a.dest.length - b.dest.length); + + // Emit final bwrap arguments + for (const m of mounts) { + if (m.type === '--tmpfs-ro') { + bwrapArgs.push('--tmpfs', m.dest, '--remount-ro', m.dest); + } else { + bwrapArgs.push(m.type, m.src, m.dest); + } + } + + return bwrapArgs; } diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts index f87dc0289c..90c80078f0 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts @@ -22,7 +22,11 @@ import { getSecureSanitizationConfig, } from '../../services/environmentSanitization.js'; import { buildSeatbeltProfile } from './seatbeltArgsBuilder.js'; -import { initializeShellParsers } from '../../utils/shell-utils.js'; +import { + initializeShellParsers, + getCommandRoots, + stripShellWrapper, +} from '../../utils/shell-utils.js'; import { isKnownSafeCommand, isDangerousCommand, @@ -133,6 +137,22 @@ export class MacOsSandboxManager implements SandboxManager { false, }; + // If the workspace is writable and we're running a git command, + // automatically allow write access to the .git directory. + const fullCmd = [command, ...args].join(' '); + const stripped = stripShellWrapper(fullCmd); + const roots = getCommandRoots(stripped).filter( + (r) => r !== 'shopt' && r !== 'set', + ); + const isGitCommand = roots.includes('git'); + + if (workspaceWrite && isGitCommand) { + const gitDir = path.join(this.options.workspace, '.git'); + if (!mergedAdditional.fileSystem!.write!.includes(gitDir)) { + mergedAdditional.fileSystem!.write!.push(gitDir); + } + } + const { command: finalCommand, args: finalArgs } = handleReadWriteCommands( req, mergedAdditional, diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts index abbf1a6d92..39d5cbe6fd 100644 --- a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts @@ -16,7 +16,7 @@ import { SECRET_FILES, type ResolvedSandboxPaths, } from '../../services/sandboxManager.js'; -import { resolveToRealPath } from '../../utils/paths.js'; +import { isSubpath, resolveToRealPath } from '../../utils/paths.js'; /** * Options for building macOS Seatbelt profile. @@ -37,6 +37,47 @@ export function escapeSchemeString(str: string): string { return str.replace(/[\\"]/g, '\\$&'); } +/** + * Checks if a path is explicitly allowed by additional write permissions. + */ +function isPathExplicitlyAllowed( + filePath: string, + realFilePath: string, + policyWrite: string[], +): boolean { + return policyWrite.some( + (p) => + p === filePath || + p === realFilePath || + isSubpath(p, filePath) || + isSubpath(p, realFilePath), + ); +} + +function denyUnlessExplicitlyAllowed( + targetPath: string, + ruleType: 'literal' | 'subpath', + policyWrite: string[], + implicitlyAllowed: boolean = false, +): string { + if (implicitlyAllowed) { + return ''; + } + + const realPath = resolveToRealPath(targetPath); + + if (isPathExplicitlyAllowed(targetPath, realPath, policyWrite)) { + return ''; // Skip if explicitly allowed + } + + let rules = `(deny file-write* (${ruleType} "${escapeSchemeString(targetPath)}"))\n`; + if (realPath !== targetPath) { + rules += `(deny file-write* (${ruleType} "${escapeSchemeString(realPath)}"))\n`; + } + + return rules; +} + /** * Builds a complete macOS Seatbelt profile string using a strict allowlist. * It embeds paths directly into the profile, properly escaped for Scheme. @@ -108,38 +149,6 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string { profile += `(allow file-read* file-write* (subpath "${escapeSchemeString(allowedPath)}"))\n`; } - // Handle granular additional read permissions - for (let i = 0; i < resolvedPaths.policyRead.length; i++) { - const resolved = resolvedPaths.policyRead[i]; - let isFile = false; - try { - isFile = fs.statSync(resolved).isFile(); - } catch { - // Ignore error - } - if (isFile) { - profile += `(allow file-read* (literal "${escapeSchemeString(resolved)}"))\n`; - } else { - profile += `(allow file-read* (subpath "${escapeSchemeString(resolved)}"))\n`; - } - } - - // Handle granular additional write permissions - for (let i = 0; i < resolvedPaths.policyWrite.length; i++) { - const resolved = resolvedPaths.policyWrite[i]; - let isFile = false; - try { - isFile = fs.statSync(resolved).isFile(); - } catch { - // Ignore error - } - if (isFile) { - profile += `(allow file-read* file-write* (literal "${escapeSchemeString(resolved)}"))\n`; - } else { - profile += `(allow file-read* file-write* (subpath "${escapeSchemeString(resolved)}"))\n`; - } - } - // Add explicit deny rules for governance files in the workspace. // These are added after the workspace allow rule to ensure they take precedence // (Seatbelt evaluates rules in order, later rules win for same path). @@ -161,13 +170,12 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string { // Ignore errors, use default guess } - const ruleType = isDirectory ? 'subpath' : 'literal'; - - profile += `(deny file-write* (${ruleType} "${escapeSchemeString(governanceFile)}"))\n`; - - if (realGovernanceFile !== governanceFile) { - profile += `(deny file-write* (${ruleType} "${escapeSchemeString(realGovernanceFile)}"))\n`; - } + profile += denyUnlessExplicitlyAllowed( + governanceFile, + isDirectory ? 'subpath' : 'literal', + resolvedPaths.policyWrite, + workspaceWrite && GOVERNANCE_FILES[i].path !== '.git', + ); } // Grant read-only access to git worktrees/submodules. We do this last in order to @@ -175,10 +183,18 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string { if (resolvedPaths.gitWorktree) { const { worktreeGitDir, mainGitDir } = resolvedPaths.gitWorktree; if (worktreeGitDir) { - profile += `(deny file-write* (subpath "${escapeSchemeString(worktreeGitDir)}"))\n`; + profile += denyUnlessExplicitlyAllowed( + worktreeGitDir, + 'subpath', + resolvedPaths.policyWrite, + ); } if (mainGitDir) { - profile += `(deny file-write* (subpath "${escapeSchemeString(mainGitDir)}"))\n`; + profile += denyUnlessExplicitlyAllowed( + mainGitDir, + 'subpath', + resolvedPaths.policyWrite, + ); } } @@ -211,6 +227,38 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string { } } + // Handle granular additional read permissions + for (let i = 0; i < resolvedPaths.policyRead.length; i++) { + const resolved = resolvedPaths.policyRead[i]; + let isFile = false; + try { + isFile = fs.statSync(resolved).isFile(); + } catch { + // Ignore error + } + if (isFile) { + profile += `(allow file-read* (literal "${escapeSchemeString(resolved)}"))\n`; + } else { + profile += `(allow file-read* (subpath "${escapeSchemeString(resolved)}"))\n`; + } + } + + // Handle granular additional write permissions + for (let i = 0; i < resolvedPaths.policyWrite.length; i++) { + const resolved = resolvedPaths.policyWrite[i]; + let isFile = false; + try { + isFile = fs.statSync(resolved).isFile(); + } catch { + // Ignore error + } + if (isFile) { + profile += `(allow file-read* file-write* (literal "${escapeSchemeString(resolved)}"))\n`; + } else { + profile += `(allow file-read* file-write* (subpath "${escapeSchemeString(resolved)}"))\n`; + } + } + // Handle forbiddenPaths const forbiddenPaths = resolvedPaths.forbidden; for (let i = 0; i < forbiddenPaths.length; i++) { diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts index 42fa196749..bf36c2e221 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts @@ -25,7 +25,13 @@ import { getSecureSanitizationConfig, } from '../../services/environmentSanitization.js'; import { debugLogger } from '../../utils/debugLogger.js'; -import { spawnAsync, getCommandName } from '../../utils/shell-utils.js'; +import { + spawnAsync, + getCommandName, + initializeShellParsers, + getCommandRoots, + stripShellWrapper, +} from '../../utils/shell-utils.js'; import { isKnownSafeCommand, isDangerousCommand, @@ -261,6 +267,14 @@ export class WindowsSandboxManager implements SandboxManager { this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false; const networkAccess = defaultNetwork || mergedAdditional.network; + await initializeShellParsers(); + const fullCmd = [command, ...args].join(' '); + const stripped = stripShellWrapper(fullCmd); + const roots = getCommandRoots(stripped).filter( + (r) => r !== 'shopt' && r !== 'set', + ); + const isGitCommand = roots.includes('git'); + const resolvedPaths = await resolveSandboxPaths( this.options, req, @@ -348,6 +362,13 @@ export class WindowsSandboxManager implements SandboxManager { if (workspaceWrite) { addWritableRoot(resolvedPaths.workspace.resolved); + + // If the workspace is writable and we're running a git command, + // automatically allow write access to the .git directory. + if (isGitCommand) { + const gitDir = path.join(resolvedPaths.workspace.resolved, '.git'); + addWritableRoot(gitDir); + } } // B. Globally included directories diff --git a/packages/core/src/services/sandboxManager.integration.test.ts b/packages/core/src/services/sandboxManager.integration.test.ts index 3481c53ca7..6fd21f1005 100644 --- a/packages/core/src/services/sandboxManager.integration.test.ts +++ b/packages/core/src/services/sandboxManager.integration.test.ts @@ -865,6 +865,336 @@ describe('SandboxManager Integration', () => { }); }); + describe('Governance Files', () => { + it('blocks write access to governance files in the workspace', async () => { + const tempWorkspace = createTempDir('workspace-'); + const gitDir = path.join(tempWorkspace, '.git'); + fs.mkdirSync(gitDir); + const testFile = path.join(gitDir, 'config'); + + const osManager = createSandboxManager( + { enabled: true }, + { workspace: tempWorkspace }, + ); + + const { command, args } = Platform.touch(testFile); + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: tempWorkspace, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'failure'); + expect(fs.existsSync(testFile)).toBe(false); + }); + + it('allows write access to governance files when explicitly requested via additionalPermissions', async () => { + const tempWorkspace = createTempDir('workspace-'); + const gitDir = path.join(tempWorkspace, '.git'); + fs.mkdirSync(gitDir); + const testFile = path.join(gitDir, 'config'); + + const osManager = createSandboxManager( + { enabled: true }, + { workspace: tempWorkspace }, + ); + + const { command, args } = Platform.touch(testFile); + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: tempWorkspace, + env: process.env, + policy: { + additionalPermissions: { fileSystem: { write: [gitDir] } }, + }, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(fs.existsSync(testFile)).toBe(true); + }); + }); + + describe('Git Worktree Support', () => { + it('allows access to git common directory in a worktree', async () => { + const mainRepo = createTempDir('main-repo-'); + const worktreeDir = createTempDir('worktree-'); + + const mainGitDir = path.join(mainRepo, '.git'); + fs.mkdirSync(mainGitDir, { recursive: true }); + fs.writeFileSync( + path.join(mainGitDir, 'config'), + '[core]\n\trepositoryformatversion = 0\n', + ); + + const worktreeGitDir = path.join( + mainGitDir, + 'worktrees', + 'test-worktree', + ); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + + // Create the .git file in the worktree directory pointing to the worktree git dir + fs.writeFileSync( + path.join(worktreeDir, '.git'), + `gitdir: ${worktreeGitDir}\n`, + ); + + // Create the backlink from worktree git dir to the worktree's .git file + const backlinkPath = path.join(worktreeGitDir, 'gitdir'); + fs.writeFileSync(backlinkPath, path.join(worktreeDir, '.git')); + + // Create a file in the worktree git dir that we want to access + const secretFile = path.join(worktreeGitDir, 'secret.txt'); + fs.writeFileSync(secretFile, 'git-secret'); + + const osManager = createSandboxManager( + { enabled: true }, + { workspace: worktreeDir }, + ); + + const { command, args } = Platform.cat(secretFile); + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: worktreeDir, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(result.stdout.trim()).toBe('git-secret'); + }); + + it('blocks write access to git common directory in a worktree', async () => { + const mainRepo = createTempDir('main-repo-'); + const worktreeDir = createTempDir('worktree-'); + + const mainGitDir = path.join(mainRepo, '.git'); + fs.mkdirSync(mainGitDir, { recursive: true }); + + const worktreeGitDir = path.join( + mainGitDir, + 'worktrees', + 'test-worktree', + ); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + + fs.writeFileSync( + path.join(worktreeDir, '.git'), + `gitdir: ${worktreeGitDir}\n`, + ); + fs.writeFileSync( + path.join(worktreeGitDir, 'gitdir'), + path.join(worktreeDir, '.git'), + ); + + const targetFile = path.join(worktreeGitDir, 'secret.txt'); + + const osManager = createSandboxManager( + { enabled: true }, + // Use YOLO mode to ensure the workspace is fully writable, but git worktrees should still be read-only + { workspace: worktreeDir, modeConfig: { yolo: true } }, + ); + + const { command, args } = Platform.touch(targetFile); + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: worktreeDir, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'failure'); + expect(fs.existsSync(targetFile)).toBe(false); + }); + + it('blocks write access to git common directory in a worktree when not explicitly requested via additionalPermissions', async () => { + const mainRepo = createTempDir('main-repo-'); + const worktreeDir = createTempDir('worktree-'); + + const mainGitDir = path.join(mainRepo, '.git'); + fs.mkdirSync(mainGitDir, { recursive: true }); + + const worktreeGitDir = path.join( + mainGitDir, + 'worktrees', + 'test-worktree', + ); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + + fs.writeFileSync( + path.join(worktreeDir, '.git'), + `gitdir: ${worktreeGitDir}\n`, + ); + fs.writeFileSync( + path.join(worktreeGitDir, 'gitdir'), + path.join(worktreeDir, '.git'), + ); + + const targetFile = path.join(worktreeGitDir, 'secret.txt'); + + const osManager = createSandboxManager( + { enabled: true }, + { workspace: worktreeDir }, + ); + + const { command, args } = Platform.touch(targetFile); + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: worktreeDir, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'failure'); + expect(fs.existsSync(targetFile)).toBe(false); + }); + + it('allows write access to git common directory in a worktree when explicitly requested via additionalPermissions', async () => { + const mainRepo = createTempDir('main-repo-'); + const worktreeDir = createTempDir('worktree-'); + + const mainGitDir = path.join(mainRepo, '.git'); + fs.mkdirSync(mainGitDir, { recursive: true }); + + const worktreeGitDir = path.join( + mainGitDir, + 'worktrees', + 'test-worktree', + ); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + + fs.writeFileSync( + path.join(worktreeDir, '.git'), + `gitdir: ${worktreeGitDir}\n`, + ); + fs.writeFileSync( + path.join(worktreeGitDir, 'gitdir'), + path.join(worktreeDir, '.git'), + ); + + const targetFile = path.join(worktreeGitDir, 'secret.txt'); + + const osManager = createSandboxManager( + { enabled: true }, + { workspace: worktreeDir }, + ); + + const { command, args } = Platform.touch(targetFile); + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: worktreeDir, + env: process.env, + policy: { + additionalPermissions: { fileSystem: { write: [worktreeGitDir] } }, + }, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(fs.existsSync(targetFile)).toBe(true); + }); + + it('allows write access to external git directory in a non-worktree environment when explicitly requested via additionalPermissions', async () => { + const externalGitDir = createTempDir('external-git-'); + const workspaceDir = createTempDir('workspace-'); + + fs.mkdirSync(externalGitDir, { recursive: true }); + + fs.writeFileSync( + path.join(workspaceDir, '.git'), + `gitdir: ${externalGitDir}\n`, + ); + + const targetFile = path.join(externalGitDir, 'secret.txt'); + + const osManager = createSandboxManager( + { enabled: true }, + { workspace: workspaceDir }, + ); + + const { command, args } = Platform.touch(targetFile); + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: workspaceDir, + env: process.env, + policy: { + additionalPermissions: { fileSystem: { write: [externalGitDir] } }, + }, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(fs.existsSync(targetFile)).toBe(true); + }); + }); + + describe('Git and Governance Write Access', () => { + it('allows write access to .gitignore when workspace is writable', async () => { + const testFile = path.join(workspace, '.gitignore'); + fs.writeFileSync(testFile, 'initial'); + + const editManager = createSandboxManager( + { enabled: true }, + { workspace, modeConfig: { readonly: false, allowOverrides: true } }, + ); + + const { command, args } = Platform.touch(testFile); + const sandboxed = await editManager.prepareCommand({ + command, + args, + cwd: workspace, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(fs.existsSync(testFile)).toBe(true); + }); + + it('automatically allows write access to .git when running git command and workspace is writable', async () => { + const gitDir = path.join(workspace, '.git'); + if (!fs.existsSync(gitDir)) fs.mkdirSync(gitDir); + const lockFile = path.join(gitDir, 'index.lock'); + + const editManager = createSandboxManager( + { enabled: true }, + { workspace, modeConfig: { readonly: false, allowOverrides: true } }, + ); + + // We use a command that looks like git to trigger the special handling. + // LinuxSandboxManager identifies the command root from the shell wrapper. + const { command: nodePath, args: nodeArgs } = Platform.touch(lockFile); + + const commandString = Platform.isWindows + ? `git --version > NUL && "${nodePath.replace(/\\/g, '/')}" ${nodeArgs + .map((a) => `'${a.replace(/\\/g, '/')}'`) + .join(' ')}` + : `git --version > /dev/null; "${nodePath}" ${nodeArgs + .map((a) => (a.includes(' ') || a.includes('(') ? `'${a}'` : a)) + .join(' ')}`; + + const sandboxed = await editManager.prepareCommand({ + command: 'sh', + args: ['-c', commandString], + cwd: workspace, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(fs.existsSync(lockFile)).toBe(true); + }); + }); + describe('Network Security', () => { describe('Network Access', () => { let server: http.Server; diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index a4dc356984..4fa3d61097 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -409,6 +409,23 @@ export async function resolveSandboxPaths( ? { gitWorktree: { worktreeGitDir, mainGitDir } } : undefined; + if (worktreeGitDir) { + const gitIdentities = new Set( + [ + path.join(options.workspace, '.git'), + path.join(resolvedWorkspace, '.git'), + ].map(toPathKey), + ); + if (policyRead.some((p) => gitIdentities.has(toPathKey(p)))) { + policyRead.push(worktreeGitDir); + if (mainGitDir) policyRead.push(mainGitDir); + } + if (policyWrite.some((p) => gitIdentities.has(toPathKey(p)))) { + policyWrite.push(worktreeGitDir); + if (mainGitDir) policyWrite.push(mainGitDir); + } + } + /** * Filters out any paths that are explicitly forbidden or match the workspace root (original or resolved). */ diff --git a/packages/core/src/telemetry/memory-monitor.test.ts b/packages/core/src/telemetry/memory-monitor.test.ts index 8ad0d45595..9cb0e91caa 100644 --- a/packages/core/src/telemetry/memory-monitor.test.ts +++ b/packages/core/src/telemetry/memory-monitor.test.ts @@ -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', () => { diff --git a/packages/core/src/telemetry/memory-monitor.ts b/packages/core/src/telemetry/memory-monitor.ts index e005bd73cc..aeaecc6ca0 100644 --- a/packages/core/src/telemetry/memory-monitor.ts +++ b/packages/core/src/telemetry/memory-monitor.ts @@ -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; diff --git a/packages/core/src/tools/definitions/base-declarations.ts b/packages/core/src/tools/definitions/base-declarations.ts index 89a5aa1614..bb0c0c3c54 100644 --- a/packages/core/src/tools/definitions/base-declarations.ts +++ b/packages/core/src/tools/definitions/base-declarations.ts @@ -137,3 +137,7 @@ export const TOPIC_PARAM_STRATEGIC_INTENT = 'strategic_intent'; // -- complete_task -- export const COMPLETE_TASK_TOOL_NAME = 'complete_task'; export const COMPLETE_TASK_DISPLAY_NAME = 'Complete Task'; + +// -- MCP Resources -- +export const READ_MCP_RESOURCE_TOOL_NAME = 'read_mcp_resource'; +export const LIST_MCP_RESOURCES_TOOL_NAME = 'list_mcp_resources'; diff --git a/packages/core/src/tools/definitions/coreTools.ts b/packages/core/src/tools/definitions/coreTools.ts index d1b81a6e99..38c2e5798c 100644 --- a/packages/core/src/tools/definitions/coreTools.ts +++ b/packages/core/src/tools/definitions/coreTools.ts @@ -43,6 +43,8 @@ export { UPDATE_TOPIC_DISPLAY_NAME, COMPLETE_TASK_TOOL_NAME, COMPLETE_TASK_DISPLAY_NAME, + READ_MCP_RESOURCE_TOOL_NAME, + LIST_MCP_RESOURCES_TOOL_NAME, // Shared parameter names PARAM_FILE_PATH, PARAM_DIR_PATH, @@ -280,3 +282,17 @@ export function getActivateSkillDefinition( overrides: (modelId) => getToolSet(modelId).activate_skill(skillNames), }; } + +export const READ_MCP_RESOURCE_DEFINITION: ToolDefinition = { + get base() { + return DEFAULT_LEGACY_SET.read_mcp_resource; + }, + overrides: (modelId) => getToolSet(modelId).read_mcp_resource, +}; + +export const LIST_MCP_RESOURCES_DEFINITION: ToolDefinition = { + get base() { + return DEFAULT_LEGACY_SET.list_mcp_resources; + }, + overrides: (modelId) => getToolSet(modelId).list_mcp_resources, +}; diff --git a/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts b/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts index 60a52fc6ad..aa801de608 100644 --- a/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts +++ b/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts @@ -25,6 +25,8 @@ import { GET_INTERNAL_DOCS_TOOL_NAME, ASK_USER_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME, + READ_MCP_RESOURCE_TOOL_NAME, + LIST_MCP_RESOURCES_TOOL_NAME, // Shared parameter names PARAM_FILE_PATH, PARAM_DIR_PATH, @@ -756,4 +758,37 @@ The agent did not use the todo list because this task could be completed by a ti exit_plan_mode: () => getExitPlanModeDeclaration(), activate_skill: (skillNames) => getActivateSkillDeclaration(skillNames), + + read_mcp_resource: { + name: READ_MCP_RESOURCE_TOOL_NAME, + description: + 'Reads the content of a specified Model Context Protocol (MCP) resource.', + parametersJsonSchema: { + type: 'object', + properties: { + uri: { + description: 'The URI of the MCP resource to read.', + type: 'string', + }, + }, + required: ['uri'], + }, + }, + + list_mcp_resources: { + name: LIST_MCP_RESOURCES_TOOL_NAME, + description: + 'Lists all available resources exposed by connected MCP servers.', + parametersJsonSchema: { + type: 'object', + properties: { + serverName: { + description: + 'Optional filter to list resources from a specific server.', + type: 'string', + }, + }, + required: [], + }, + }, }; diff --git a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts index a86a20378e..03872b045d 100644 --- a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts +++ b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts @@ -25,6 +25,8 @@ import { GET_INTERNAL_DOCS_TOOL_NAME, ASK_USER_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME, + READ_MCP_RESOURCE_TOOL_NAME, + LIST_MCP_RESOURCES_TOOL_NAME, // Shared parameter names PARAM_FILE_PATH, PARAM_DIR_PATH, @@ -733,4 +735,37 @@ The agent did not use the todo list because this task could be completed by a ti exit_plan_mode: () => getExitPlanModeDeclaration(), activate_skill: (skillNames) => getActivateSkillDeclaration(skillNames), update_topic: getUpdateTopicDeclaration(), + + read_mcp_resource: { + name: READ_MCP_RESOURCE_TOOL_NAME, + description: + 'Reads the content of a specified Model Context Protocol (MCP) resource.', + parametersJsonSchema: { + type: 'object', + properties: { + uri: { + description: 'The URI of the MCP resource to read.', + type: 'string', + }, + }, + required: ['uri'], + }, + }, + + list_mcp_resources: { + name: LIST_MCP_RESOURCES_TOOL_NAME, + description: + 'Lists all available resources exposed by connected MCP servers.', + parametersJsonSchema: { + type: 'object', + properties: { + serverName: { + description: + 'Optional filter to list resources from a specific server.', + type: 'string', + }, + }, + required: [], + }, + }, }; diff --git a/packages/core/src/tools/definitions/types.ts b/packages/core/src/tools/definitions/types.ts index 42c0cc7028..06f946e23f 100644 --- a/packages/core/src/tools/definitions/types.ts +++ b/packages/core/src/tools/definitions/types.ts @@ -50,5 +50,7 @@ export interface CoreToolSet { enter_plan_mode: FunctionDeclaration; exit_plan_mode: () => FunctionDeclaration; activate_skill: (skillNames: string[]) => FunctionDeclaration; + read_mcp_resource: FunctionDeclaration; + list_mcp_resources: FunctionDeclaration; update_topic?: FunctionDeclaration; } diff --git a/packages/core/src/tools/get-internal-docs.ts b/packages/core/src/tools/get-internal-docs.ts index 5d2f8821ae..0258251f81 100644 --- a/packages/core/src/tools/get-internal-docs.ts +++ b/packages/core/src/tools/get-internal-docs.ts @@ -102,8 +102,12 @@ class GetInternalDocsInvocation extends BaseToolInvocation< const docsRoot = await getDocsRoot(); if (!this.params.path) { - // List all .md files recursively - const files = await glob('**/*.md', { cwd: docsRoot, posix: true }); + // List all .md and .mdx files recursively + const files = await glob('**/*.{md,mdx}', { + cwd: docsRoot, + posix: true, + }); + files.sort(); const fileList = files.map((f) => `- ${f}`).join('\n'); diff --git a/packages/core/src/tools/list-mcp-resources.test.ts b/packages/core/src/tools/list-mcp-resources.test.ts new file mode 100644 index 0000000000..abc44842ee --- /dev/null +++ b/packages/core/src/tools/list-mcp-resources.test.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { ListMcpResourcesTool } from './list-mcp-resources.js'; +import { ToolErrorType } from './tool-error.js'; +import type { AgentLoopContext } from '../config/agent-loop-context.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; + +describe('ListMcpResourcesTool', () => { + let tool: ListMcpResourcesTool; + let mockContext: { + config: { + getMcpClientManager: Mock; + }; + }; + let mockMcpManager: { + getAllResources: Mock; + }; + const abortSignal = new AbortController().signal; + + beforeEach(() => { + mockMcpManager = { + getAllResources: vi.fn(), + }; + + mockContext = { + config: { + getMcpClientManager: vi.fn().mockReturnValue(mockMcpManager), + }, + }; + + tool = new ListMcpResourcesTool( + mockContext as unknown as AgentLoopContext, + createMockMessageBus(), + ); + }); + + it('should successfully list all resources', async () => { + const resources = [ + { + uri: 'protocol://r1', + serverName: 'server1', + name: 'R1', + description: 'D1', + }, + { uri: 'protocol://r2', serverName: 'server2', name: 'R2' }, + ]; + mockMcpManager.getAllResources.mockReturnValue(resources); + + const invocation = ( + tool as unknown as { + createInvocation: (params: Record) => { + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).createInvocation({}); + const result = (await invocation.execute({ abortSignal })) as { + llmContent: string; + returnDisplay: string; + }; + + expect(mockMcpManager.getAllResources).toHaveBeenCalled(); + expect(result.llmContent).toContain('Available MCP Resources:'); + expect(result.llmContent).toContain('protocol://r1'); + expect(result.llmContent).toContain('protocol://r2'); + expect(result.returnDisplay).toBe('Listed 2 resources.'); + }); + + it('should filter by server name', async () => { + const resources = [ + { uri: 'protocol://r1', serverName: 'server1', name: 'R1' }, + { uri: 'protocol://r2', serverName: 'server2', name: 'R2' }, + ]; + mockMcpManager.getAllResources.mockReturnValue(resources); + + const invocation = ( + tool as unknown as { + createInvocation: (params: Record) => { + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).createInvocation({ serverName: 'server1' }); + const result = (await invocation.execute({ abortSignal })) as { + llmContent: string; + returnDisplay: string; + }; + + expect(result.llmContent).toContain('protocol://r1'); + expect(result.llmContent).not.toContain('protocol://r2'); + expect(result.returnDisplay).toBe('Listed 1 resources.'); + }); + + it('should return message if no resources found', async () => { + mockMcpManager.getAllResources.mockReturnValue([]); + + const invocation = ( + tool as unknown as { + createInvocation: (params: Record) => { + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).createInvocation({}); + const result = (await invocation.execute({ abortSignal })) as { + llmContent: string; + returnDisplay: string; + }; + + expect(result.llmContent).toBe('No MCP resources found.'); + expect(result.returnDisplay).toBe('No MCP resources found.'); + }); + + it('should return message if no resources found for server', async () => { + mockMcpManager.getAllResources.mockReturnValue([]); + + const invocation = ( + tool as unknown as { + createInvocation: (params: Record) => { + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).createInvocation({ serverName: 'nonexistent' }); + const result = (await invocation.execute({ abortSignal })) as { + llmContent: string; + returnDisplay: string; + }; + + expect(result.llmContent).toBe( + 'No resources found for server: nonexistent', + ); + expect(result.returnDisplay).toBe( + 'No resources found for server: nonexistent', + ); + }); + + it('should return error if MCP Client Manager not available', async () => { + mockContext.config.getMcpClientManager.mockReturnValue(undefined); + + const invocation = ( + tool as unknown as { + createInvocation: (params: Record) => { + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).createInvocation({}); + const result = (await invocation.execute({ abortSignal })) as { + error: { type: string; message: string }; + }; + + expect(result.error?.type).toBe(ToolErrorType.EXECUTION_FAILED); + expect(result.error?.message).toContain('MCP Client Manager not available'); + }); +}); diff --git a/packages/core/src/tools/list-mcp-resources.ts b/packages/core/src/tools/list-mcp-resources.ts new file mode 100644 index 0000000000..0787bf3900 --- /dev/null +++ b/packages/core/src/tools/list-mcp-resources.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + type ToolResult, + type ExecuteOptions, +} from './tools.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import { LIST_MCP_RESOURCES_TOOL_NAME } from './tool-names.js'; +import { LIST_MCP_RESOURCES_DEFINITION } from './definitions/coreTools.js'; +import type { AgentLoopContext } from '../config/agent-loop-context.js'; +import { ToolErrorType } from './tool-error.js'; + +export interface ListMcpResourcesParams { + serverName?: string; +} + +export class ListMcpResourcesTool extends BaseDeclarativeTool< + ListMcpResourcesParams, + ToolResult +> { + static readonly Name = LIST_MCP_RESOURCES_TOOL_NAME; + + constructor( + private readonly context: AgentLoopContext, + messageBus: MessageBus, + ) { + super( + ListMcpResourcesTool.Name, + 'List MCP Resources', + LIST_MCP_RESOURCES_DEFINITION.base.description!, + Kind.Search, + LIST_MCP_RESOURCES_DEFINITION.base.parametersJsonSchema, + messageBus, + true, + false, + ); + } + + protected createInvocation( + params: ListMcpResourcesParams, + ): ListMcpResourcesToolInvocation { + return new ListMcpResourcesToolInvocation( + this.context, + params, + this.messageBus, + ); + } +} + +class ListMcpResourcesToolInvocation extends BaseToolInvocation< + ListMcpResourcesParams, + ToolResult +> { + constructor( + private readonly context: AgentLoopContext, + params: ListMcpResourcesParams, + messageBus: MessageBus, + ) { + super(params, messageBus, ListMcpResourcesTool.Name, 'List MCP Resources'); + } + + getDescription(): string { + return 'List MCP resources'; + } + + async execute({ + abortSignal: _abortSignal, + }: ExecuteOptions): Promise { + const mcpManager = this.context.config.getMcpClientManager(); + if (!mcpManager) { + return { + llmContent: 'Error: MCP Client Manager not available.', + returnDisplay: 'Error: MCP Client Manager not available.', + error: { + message: 'MCP Client Manager not available.', + type: ToolErrorType.EXECUTION_FAILED, + }, + }; + } + + let resources = mcpManager.getAllResources(); + + const serverName = this.params.serverName; + if (serverName) { + resources = resources.filter((r) => r.serverName === serverName); + } + + if (resources.length === 0) { + const msg = serverName + ? `No resources found for server: ${serverName}` + : 'No MCP resources found.'; + return { + llmContent: msg, + returnDisplay: msg, + }; + } + + // Format the list + let content = 'Available MCP Resources:\n'; + for (const resource of resources) { + content += `- ${resource.serverName}:${resource.uri}`; + if (resource.name) { + content += ` | ${resource.name}`; + } + if (resource.description) { + content += ` | ${resource.description}`; + } + content += '\n'; + } + + return { + llmContent: content, + returnDisplay: `Listed ${resources.length} resources.`, + }; + } +} diff --git a/packages/core/src/tools/mcp-client-manager.test.ts b/packages/core/src/tools/mcp-client-manager.test.ts index a96f3f7d29..83aa2b59a4 100644 --- a/packages/core/src/tools/mcp-client-manager.test.ts +++ b/packages/core/src/tools/mcp-client-manager.test.ts @@ -821,4 +821,64 @@ describe('McpClientManager', () => { expect(coreEventsMock.emitFeedback).toHaveBeenCalledTimes(2); // Now the actual error }); }); + + describe('findResourceByUri', () => { + it('should find resource by exact URI match', () => { + const mockResource = { uri: 'test://resource1', name: 'Resource 1' }; + const mockResourceRegistry = { + getAllResources: vi.fn().mockReturnValue([mockResource]), + findResourceByUri: vi.fn(), + }; + mockConfig.getResourceRegistry.mockReturnValue( + mockResourceRegistry as unknown as ResourceRegistry, + ); + + const manager = setupManager(new McpClientManager('0.0.1', mockConfig)); + + const result = manager.findResourceByUri('test://resource1'); + expect(result).toBe(mockResource); + }); + + it('should try ResourceRegistry.findResourceByUri first', () => { + const mockResourceQualified = { + uri: 'test://resource1', + name: 'Resource 1 Qualified', + }; + const mockResourceDirect = { + uri: 'test-server:test://resource1', + name: 'Resource 1 Direct', + }; + const mockResourceRegistry = { + getAllResources: vi.fn().mockReturnValue([mockResourceDirect]), + findResourceByUri: vi.fn().mockReturnValue(mockResourceQualified), + }; + mockConfig.getResourceRegistry.mockReturnValue( + mockResourceRegistry as unknown as ResourceRegistry, + ); + + const manager = setupManager(new McpClientManager('0.0.1', mockConfig)); + + const result = manager.findResourceByUri('test-server:test://resource1'); + expect(result).toBe(mockResourceQualified); + expect(mockResourceRegistry.findResourceByUri).toHaveBeenCalledWith( + 'test-server:test://resource1', + ); + expect(mockResourceRegistry.getAllResources).not.toHaveBeenCalled(); + }); + + it('should return undefined if both fail', () => { + const mockResourceRegistry = { + getAllResources: vi.fn().mockReturnValue([]), + findResourceByUri: vi.fn().mockReturnValue(undefined), + }; + mockConfig.getResourceRegistry.mockReturnValue( + mockResourceRegistry as unknown as ResourceRegistry, + ); + + const manager = setupManager(new McpClientManager('0.0.1', mockConfig)); + + const result = manager.findResourceByUri('non-existent'); + expect(result).toBeUndefined(); + }); + }); }); diff --git a/packages/core/src/tools/mcp-client-manager.ts b/packages/core/src/tools/mcp-client-manager.ts index 3e7ef75d4c..b109e2ac03 100644 --- a/packages/core/src/tools/mcp-client-manager.ts +++ b/packages/core/src/tools/mcp-client-manager.ts @@ -24,7 +24,10 @@ import { debugLogger } from '../utils/debugLogger.js'; import { createHash } from 'node:crypto'; import { stableStringify } from '../policy/stable-stringify.js'; import type { PromptRegistry } from '../prompts/prompt-registry.js'; -import type { ResourceRegistry } from '../resources/resource-registry.js'; +import type { + ResourceRegistry, + MCPResource, +} from '../resources/resource-registry.js'; /** * Manages the lifecycle of multiple MCP clients, including local child processes. @@ -161,7 +164,32 @@ export class McpClientManager { } getClient(serverName: string): McpClient | undefined { - return this.clients.get(serverName); + for (const client of this.clients.values()) { + if (client.getServerName() === serverName) { + return client; + } + } + return undefined; + } + + findResourceByUri(uri: string): MCPResource | undefined { + if (!this.mainResourceRegistry) return undefined; + + // Try serverName:uri format first + const qualifiedMatch = this.mainResourceRegistry.findResourceByUri(uri); + if (qualifiedMatch) { + return qualifiedMatch; + } + + // Try direct URI match + return this.mainResourceRegistry + .getAllResources() + .find((r) => r.uri === uri); + } + + getAllResources(): MCPResource[] { + if (!this.mainResourceRegistry) return []; + return this.mainResourceRegistry.getAllResources(); } removeRegistries(registries: { diff --git a/packages/core/src/tools/read-mcp-resource.test.ts b/packages/core/src/tools/read-mcp-resource.test.ts new file mode 100644 index 0000000000..f548b934e2 --- /dev/null +++ b/packages/core/src/tools/read-mcp-resource.test.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { ReadMcpResourceTool } from './read-mcp-resource.js'; +import { ToolErrorType } from './tool-error.js'; +import type { AgentLoopContext } from '../config/agent-loop-context.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; + +describe('ReadMcpResourceTool', () => { + let tool: ReadMcpResourceTool; + let mockContext: { + config: { + getMcpClientManager: Mock; + }; + }; + let mockMcpManager: { + findResourceByUri: Mock; + getClient: Mock; + }; + const abortSignal = new AbortController().signal; + + beforeEach(() => { + mockMcpManager = { + findResourceByUri: vi.fn(), + getClient: vi.fn(), + }; + + mockContext = { + config: { + getMcpClientManager: vi.fn().mockReturnValue(mockMcpManager), + }, + }; + + tool = new ReadMcpResourceTool( + mockContext as unknown as AgentLoopContext, + createMockMessageBus(), + ); + }); + + it('should successfully read a resource', async () => { + const uri = 'protocol://resource'; + const serverName = 'test-server'; + const resourceName = 'Test Resource'; + const resourceContent = 'Resource Content'; + + mockMcpManager.findResourceByUri.mockReturnValue({ + uri, + serverName, + name: resourceName, + }); + + const mockClient = { + readResource: vi.fn().mockResolvedValue({ + contents: [{ text: resourceContent }], + }), + }; + mockMcpManager.getClient.mockReturnValue(mockClient); + + const invocation = ( + tool as unknown as { + createInvocation: (params: Record) => { + getDescription: () => string; + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).createInvocation({ uri }); + + // Verify description + expect(invocation.getDescription()).toBe( + `Read MCP resource "${resourceName}" from server "${serverName}"`, + ); + + const result = (await invocation.execute({ abortSignal })) as { + llmContent: string; + returnDisplay: string; + }; + + expect(mockMcpManager.findResourceByUri).toHaveBeenCalledWith(uri); + expect(mockMcpManager.getClient).toHaveBeenCalledWith(serverName); + expect(mockClient.readResource).toHaveBeenCalledWith(uri); + expect(result).toEqual({ + llmContent: resourceContent + '\n', + returnDisplay: `Successfully read resource "${resourceName}" from server "${serverName}"`, + }); + }); + + it('should pass raw URI to client when using qualified URI', async () => { + const qualifiedUri = 'test-server:protocol://resource'; + const rawUri = 'protocol://resource'; + const serverName = 'test-server'; + const resourceName = 'Test Resource'; + const resourceContent = 'Resource Content'; + + mockMcpManager.findResourceByUri.mockReturnValue({ + uri: rawUri, + serverName, + name: resourceName, + }); + + const mockClient = { + readResource: vi.fn().mockResolvedValue({ + contents: [{ text: resourceContent }], + }), + }; + mockMcpManager.getClient.mockReturnValue(mockClient); + + const invocation = ( + tool as unknown as { + createInvocation: (params: Record) => { + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).createInvocation({ uri: qualifiedUri }); + + const result = (await invocation.execute({ abortSignal })) as { + llmContent: string; + returnDisplay: string; + }; + + expect(mockMcpManager.findResourceByUri).toHaveBeenCalledWith(qualifiedUri); + expect(mockMcpManager.getClient).toHaveBeenCalledWith(serverName); + expect(mockClient.readResource).toHaveBeenCalledWith(rawUri); + expect(result.llmContent).toBe(resourceContent + '\n'); + }); + + it('should return error if MCP Client Manager not available', async () => { + mockContext.config.getMcpClientManager.mockReturnValue(undefined); + + const invocation = ( + tool as unknown as { + createInvocation: (params: Record) => { + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).createInvocation({ uri: 'uri' }); + const result = (await invocation.execute({ abortSignal })) as { + error: { type: string; message: string }; + }; + + expect(result.error?.type).toBe(ToolErrorType.EXECUTION_FAILED); + expect(result.error?.message).toContain('MCP Client Manager not available'); + }); + + it('should return error if resource not found', async () => { + mockMcpManager.findResourceByUri.mockReturnValue(undefined); + + const invocation = ( + tool as unknown as { + createInvocation: (params: Record) => { + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).createInvocation({ uri: 'uri' }); + const result = (await invocation.execute({ abortSignal })) as { + error: { type: string; message: string }; + }; + + expect(result.error?.type).toBe(ToolErrorType.MCP_RESOURCE_NOT_FOUND); + expect(result.error?.message).toContain('Resource not found'); + }); + + it('should return error if reading fails', async () => { + const uri = 'protocol://resource'; + const serverName = 'test-server'; + + mockMcpManager.findResourceByUri.mockReturnValue({ + uri, + serverName, + }); + + const mockClient = { + readResource: vi.fn().mockRejectedValue(new Error('Failed to read')), + }; + mockMcpManager.getClient.mockReturnValue(mockClient); + + const invocation = ( + tool as unknown as { + createInvocation: (params: Record) => { + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).createInvocation({ uri }); + const result = (await invocation.execute({ abortSignal })) as { + error: { type: string; message: string }; + }; + + expect(result.error?.type).toBe(ToolErrorType.MCP_TOOL_ERROR); + expect(result.error?.message).toContain('Failed to read resource'); + }); +}); diff --git a/packages/core/src/tools/read-mcp-resource.ts b/packages/core/src/tools/read-mcp-resource.ts new file mode 100644 index 0000000000..13105afa10 --- /dev/null +++ b/packages/core/src/tools/read-mcp-resource.ts @@ -0,0 +1,169 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + type ToolResult, + type ExecuteOptions, +} from './tools.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import { READ_MCP_RESOURCE_TOOL_NAME } from './tool-names.js'; +import { READ_MCP_RESOURCE_DEFINITION } from './definitions/coreTools.js'; +import type { AgentLoopContext } from '../config/agent-loop-context.js'; +import { ToolErrorType } from './tool-error.js'; +import type { MCPResource } from '../resources/resource-registry.js'; + +export interface ReadMcpResourceParams { + uri: string; +} + +export class ReadMcpResourceTool extends BaseDeclarativeTool< + ReadMcpResourceParams, + ToolResult +> { + static readonly Name = READ_MCP_RESOURCE_TOOL_NAME; + + constructor( + private readonly context: AgentLoopContext, + messageBus: MessageBus, + ) { + super( + ReadMcpResourceTool.Name, + 'Read MCP Resource', + READ_MCP_RESOURCE_DEFINITION.base.description!, + Kind.Read, + READ_MCP_RESOURCE_DEFINITION.base.parametersJsonSchema, + messageBus, + true, + false, + ); + } + + protected createInvocation( + params: ReadMcpResourceParams, + ): ReadMcpResourceToolInvocation { + return new ReadMcpResourceToolInvocation( + this.context, + params, + this.messageBus, + ); + } +} + +class ReadMcpResourceToolInvocation extends BaseToolInvocation< + ReadMcpResourceParams, + ToolResult +> { + private resource: MCPResource | undefined; + + constructor( + private readonly context: AgentLoopContext, + params: ReadMcpResourceParams, + messageBus: MessageBus, + ) { + super(params, messageBus, ReadMcpResourceTool.Name, 'Read MCP Resource'); + const mcpManager = this.context.config.getMcpClientManager(); + this.resource = mcpManager?.findResourceByUri(params.uri); + } + + getDescription(): string { + if (this.resource) { + return `Read MCP resource "${this.resource.name}" from server "${this.resource.serverName}"`; + } + return `Read MCP resource: ${this.params.uri}`; + } + + async execute({ + abortSignal: _abortSignal, + }: ExecuteOptions): Promise { + const mcpManager = this.context.config.getMcpClientManager(); + if (!mcpManager) { + return { + llmContent: 'Error: MCP Client Manager not available.', + returnDisplay: 'Error: MCP Client Manager not available.', + error: { + message: 'MCP Client Manager not available.', + type: ToolErrorType.EXECUTION_FAILED, + }, + }; + } + + const uri = this.params.uri; + if (!uri) { + return { + llmContent: 'Error: No URI provided.', + returnDisplay: 'Error: No URI provided.', + error: { + message: 'No URI provided.', + type: ToolErrorType.INVALID_TOOL_PARAMS, + }, + }; + } + + const resource = mcpManager.findResourceByUri(uri); + if (!resource) { + const errorMessage = `Resource not found for URI: ${uri}`; + return { + llmContent: `Error: ${errorMessage}`, + returnDisplay: `Error: ${errorMessage}`, + error: { + message: errorMessage, + type: ToolErrorType.MCP_RESOURCE_NOT_FOUND, + }, + }; + } + + const client = mcpManager.getClient(resource.serverName); + if (!client) { + const errorMessage = `MCP Client not found for server: ${resource.serverName}`; + return { + llmContent: `Error: ${errorMessage}`, + returnDisplay: `Error: ${errorMessage}`, + error: { + message: errorMessage, + type: ToolErrorType.EXECUTION_FAILED, + }, + }; + } + + try { + const result = await client.readResource(resource.uri); + // The result should contain contents. + // Let's assume it returns a string or an object with contents. + // According to MCP spec, it returns { contents: [...] }. + // We should format it nicely. + let contentText = ''; + if (result && result.contents) { + for (const content of result.contents) { + if ('text' in content && content.text) { + contentText += content.text + '\n'; + } else if ('blob' in content && content.blob) { + contentText += `[Binary Data (${content.mimeType})]` + '\n'; + } + } + } + + return { + llmContent: contentText || 'No content returned from resource.', + returnDisplay: this.resource + ? `Successfully read resource "${this.resource.name}" from server "${this.resource.serverName}"` + : `Successfully read resource: ${uri}`, + }; + } catch (e) { + const errorMessage = `Failed to read resource: ${e instanceof Error ? e.message : String(e)}`; + return { + llmContent: `Error: ${errorMessage}`, + returnDisplay: `Error: ${errorMessage}`, + error: { + message: errorMessage, + type: ToolErrorType.MCP_TOOL_ERROR, + }, + }; + } + } +} diff --git a/packages/core/src/tools/ripGrep.test.ts b/packages/core/src/tools/ripGrep.test.ts index 000e3db3e1..9ad575833a 100644 --- a/packages/core/src/tools/ripGrep.test.ts +++ b/packages/core/src/tools/ripGrep.test.ts @@ -4,20 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - describe, - it, - expect, - beforeEach, - afterEach, - afterAll, - vi, -} from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { canUseRipgrep, RipGrepTool, ensureRgPath, type RipGrepToolParams, + getRipgrepPath, } from './ripGrep.js'; import type { GrepResult } from './tools.js'; import path from 'node:path'; @@ -25,18 +18,21 @@ import { isSubpath } from '../utils/paths.js'; import fs from 'node:fs/promises'; import os from 'node:os'; import type { Config } from '../config/config.js'; -import { Storage } from '../config/storage.js'; import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; import { spawn, type ChildProcess } from 'node:child_process'; import { PassThrough, Readable } from 'node:stream'; import EventEmitter from 'node:events'; -import { downloadRipGrep } from '@joshua.litt/get-ripgrep'; import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; -// Mock dependencies for canUseRipgrep -vi.mock('@joshua.litt/get-ripgrep', () => ({ - downloadRipGrep: vi.fn(), -})); +import { fileExists } from '../utils/fileUtils.js'; + +vi.mock('../utils/fileUtils.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fileExists: vi.fn(), + }; +}); // Mock child_process for ripgrep calls vi.mock('child_process', () => ({ @@ -44,161 +40,42 @@ vi.mock('child_process', () => ({ })); const mockSpawn = vi.mocked(spawn); -const downloadRipGrepMock = vi.mocked(downloadRipGrep); -const originalGetGlobalBinDir = Storage.getGlobalBinDir.bind(Storage); -const storageSpy = vi.spyOn(Storage, 'getGlobalBinDir'); - -function getRipgrepBinaryName() { - return process.platform === 'win32' ? 'rg.exe' : 'rg'; -} describe('canUseRipgrep', () => { - let tempRootDir: string; - let binDir: string; - - beforeEach(async () => { - downloadRipGrepMock.mockReset(); - downloadRipGrepMock.mockResolvedValue(undefined); - tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-bin-')); - binDir = path.join(tempRootDir, 'bin'); - await fs.mkdir(binDir, { recursive: true }); - storageSpy.mockImplementation(() => binDir); - }); - - afterEach(async () => { - storageSpy.mockImplementation(() => originalGetGlobalBinDir()); - await fs.rm(tempRootDir, { recursive: true, force: true }); + beforeEach(() => { + vi.mocked(fileExists).mockReset(); }); it('should return true if ripgrep already exists', async () => { - const existingPath = path.join(binDir, getRipgrepBinaryName()); - await fs.writeFile(existingPath, ''); - + vi.mocked(fileExists).mockResolvedValue(true); const result = await canUseRipgrep(); expect(result).toBe(true); - expect(downloadRipGrepMock).not.toHaveBeenCalled(); }); - it('should download ripgrep and return true if it does not exist initially', async () => { - const expectedPath = path.join(binDir, getRipgrepBinaryName()); - - downloadRipGrepMock.mockImplementation(async () => { - await fs.writeFile(expectedPath, ''); - }); - + it('should return false if file does not exist', async () => { + vi.mocked(fileExists).mockResolvedValue(false); const result = await canUseRipgrep(); - - expect(result).toBe(true); - expect(downloadRipGrep).toHaveBeenCalledWith(binDir); - await expect(fs.access(expectedPath)).resolves.toBeUndefined(); - }); - - it('should return false if download fails and file does not exist', async () => { - const result = await canUseRipgrep(); - expect(result).toBe(false); - expect(downloadRipGrep).toHaveBeenCalledWith(binDir); - }); - - it('should propagate errors from downloadRipGrep', async () => { - const error = new Error('Download failed'); - downloadRipGrepMock.mockRejectedValue(error); - - await expect(canUseRipgrep()).rejects.toThrow(error); - expect(downloadRipGrep).toHaveBeenCalledWith(binDir); - }); - - it('should only download once when called concurrently', async () => { - const expectedPath = path.join(binDir, getRipgrepBinaryName()); - - downloadRipGrepMock.mockImplementation( - () => - new Promise((resolve, reject) => { - setTimeout(() => { - fs.writeFile(expectedPath, '') - .then(() => resolve()) - .catch(reject); - }, 0); - }), - ); - - const firstCall = ensureRgPath(); - const secondCall = ensureRgPath(); - - const [pathOne, pathTwo] = await Promise.all([firstCall, secondCall]); - - expect(pathOne).toBe(expectedPath); - expect(pathTwo).toBe(expectedPath); - expect(downloadRipGrepMock).toHaveBeenCalledTimes(1); - await expect(fs.access(expectedPath)).resolves.toBeUndefined(); }); }); describe('ensureRgPath', () => { - let tempRootDir: string; - let binDir: string; - - beforeEach(async () => { - downloadRipGrepMock.mockReset(); - downloadRipGrepMock.mockResolvedValue(undefined); - tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-bin-')); - binDir = path.join(tempRootDir, 'bin'); - await fs.mkdir(binDir, { recursive: true }); - storageSpy.mockImplementation(() => binDir); - }); - - afterEach(async () => { - storageSpy.mockImplementation(() => originalGetGlobalBinDir()); - await fs.rm(tempRootDir, { recursive: true, force: true }); + beforeEach(() => { + vi.mocked(fileExists).mockReset(); }); it('should return rg path if ripgrep already exists', async () => { - const existingPath = path.join(binDir, getRipgrepBinaryName()); - await fs.writeFile(existingPath, ''); - + vi.mocked(fileExists).mockResolvedValue(true); const rgPath = await ensureRgPath(); - expect(rgPath).toBe(existingPath); - expect(downloadRipGrep).not.toHaveBeenCalled(); + expect(rgPath).toBe(await getRipgrepPath()); }); - it('should return rg path if ripgrep is downloaded successfully', async () => { - const expectedPath = path.join(binDir, getRipgrepBinaryName()); - - downloadRipGrepMock.mockImplementation(async () => { - await fs.writeFile(expectedPath, ''); - }); - - const rgPath = await ensureRgPath(); - expect(rgPath).toBe(expectedPath); - expect(downloadRipGrep).toHaveBeenCalledTimes(1); - await expect(fs.access(expectedPath)).resolves.toBeUndefined(); + it('should throw an error if ripgrep cannot be used', async () => { + vi.mocked(fileExists).mockResolvedValue(false); + await expect(ensureRgPath()).rejects.toThrow( + /Cannot find bundled ripgrep binary/, + ); }); - - it('should throw an error if ripgrep cannot be used after download attempt', async () => { - await expect(ensureRgPath()).rejects.toThrow('Cannot use ripgrep.'); - expect(downloadRipGrep).toHaveBeenCalledTimes(1); - }); - - it('should propagate errors from downloadRipGrep', async () => { - const error = new Error('Download failed'); - downloadRipGrepMock.mockRejectedValue(error); - - await expect(ensureRgPath()).rejects.toThrow(error); - expect(downloadRipGrep).toHaveBeenCalledWith(binDir); - }); - - it.runIf(process.platform === 'win32')( - 'should detect ripgrep when only rg.exe exists on Windows', - async () => { - const expectedRgExePath = path.join(binDir, 'rg.exe'); - await fs.writeFile(expectedRgExePath, ''); - - const rgPath = await ensureRgPath(); - expect(rgPath).toBe(expectedRgExePath); - expect(downloadRipGrep).not.toHaveBeenCalled(); - await expect(fs.access(expectedRgExePath)).resolves.toBeUndefined(); - }, - ); }); // Helper function to create mock spawn implementations @@ -247,9 +124,6 @@ function createMockSpawn( describe('RipGrepTool', () => { let tempRootDir: string; - let tempBinRoot: string; - let binDir: string; - let ripgrepBinaryPath: string; let grepTool: RipGrepTool; const abortSignal = new AbortController().signal; @@ -266,19 +140,12 @@ describe('RipGrepTool', () => { } as unknown as Config; beforeEach(async () => { - downloadRipGrepMock.mockReset(); - downloadRipGrepMock.mockResolvedValue(undefined); mockSpawn.mockReset(); mockSpawn.mockImplementation(createMockSpawn()); - tempBinRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-bin-')); - binDir = path.join(tempBinRoot, 'bin'); - await fs.mkdir(binDir, { recursive: true }); - const binaryName = process.platform === 'win32' ? 'rg.exe' : 'rg'; - ripgrepBinaryPath = path.join(binDir, binaryName); - await fs.writeFile(ripgrepBinaryPath, ''); - storageSpy.mockImplementation(() => binDir); tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-')); + vi.mocked(fileExists).mockResolvedValue(true); + mockConfig = { getTargetDir: () => tempRootDir, getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), @@ -335,9 +202,7 @@ describe('RipGrepTool', () => { }); afterEach(async () => { - storageSpy.mockImplementation(() => originalGetGlobalBinDir()); await fs.rm(tempRootDir, { recursive: true, force: true }); - await fs.rm(tempBinRoot, { recursive: true, force: true }); }); describe('validateToolParams', () => { @@ -834,16 +699,16 @@ describe('RipGrepTool', () => { }); it('should throw an error if ripgrep is not available', async () => { - await fs.rm(ripgrepBinaryPath, { force: true }); - downloadRipGrepMock.mockResolvedValue(undefined); + vi.mocked(fileExists).mockResolvedValue(false); const params: RipGrepToolParams = { pattern: 'world' }; const invocation = grepTool.build(params); - expect(await invocation.execute({ abortSignal })).toStrictEqual({ - llmContent: 'Error during grep search operation: Cannot use ripgrep.', - returnDisplay: 'Error: Cannot use ripgrep.', - }); + const result = await invocation.execute({ abortSignal }); + expect(result.llmContent).toContain('Cannot find bundled ripgrep binary'); + + // restore the mock for subsequent tests + vi.mocked(fileExists).mockResolvedValue(true); }); }); @@ -2080,6 +1945,68 @@ describe('RipGrepTool', () => { }); }); -afterAll(() => { - storageSpy.mockRestore(); +describe('getRipgrepPath', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('OS/Architecture Resolution', () => { + it.each([ + { platform: 'darwin', arch: 'arm64', expectedBin: 'rg-darwin-arm64' }, + { platform: 'darwin', arch: 'x64', expectedBin: 'rg-darwin-x64' }, + { platform: 'linux', arch: 'arm64', expectedBin: 'rg-linux-arm64' }, + { platform: 'linux', arch: 'x64', expectedBin: 'rg-linux-x64' }, + { platform: 'win32', arch: 'x64', expectedBin: 'rg-win32-x64.exe' }, + ])( + 'should map $platform $arch to $expectedBin', + async ({ platform, arch, expectedBin }) => { + vi.spyOn(os, 'platform').mockReturnValue(platform as NodeJS.Platform); + vi.spyOn(os, 'arch').mockReturnValue(arch); + vi.mocked(fileExists).mockImplementation(async (checkPath) => + checkPath.endsWith(expectedBin), + ); + + const resolvedPath = await getRipgrepPath(); + expect(resolvedPath).not.toBeNull(); + expect(resolvedPath?.endsWith(expectedBin)).toBe(true); + }, + ); + }); + + describe('Path Fallback Logic', () => { + beforeEach(() => { + vi.spyOn(os, 'platform').mockReturnValue('linux'); + vi.spyOn(os, 'arch').mockReturnValue('x64'); + }); + + it('should resolve the SEA (flattened) path first', async () => { + vi.mocked(fileExists).mockImplementation(async (checkPath) => + checkPath.includes(path.normalize('tools/vendor/ripgrep')), + ); + + const resolvedPath = await getRipgrepPath(); + expect(resolvedPath).not.toBeNull(); + expect(resolvedPath).toContain(path.normalize('tools/vendor/ripgrep')); + }); + + it('should fall back to the Dev path if SEA path is missing', async () => { + vi.mocked(fileExists).mockImplementation( + async (checkPath) => + checkPath.includes(path.normalize('core/vendor/ripgrep')) && + !checkPath.includes('tools'), + ); + + const resolvedPath = await getRipgrepPath(); + expect(resolvedPath).not.toBeNull(); + expect(resolvedPath).toContain(path.normalize('core/vendor/ripgrep')); + expect(resolvedPath).not.toContain('tools'); + }); + + it('should return null if binary is missing from both paths', async () => { + vi.mocked(fileExists).mockResolvedValue(false); + + const resolvedPath = await getRipgrepPath(); + expect(resolvedPath).toBeNull(); + }); + }); }); diff --git a/packages/core/src/tools/ripGrep.ts b/packages/core/src/tools/ripGrep.ts index 4449a7a08a..c2ae482289 100644 --- a/packages/core/src/tools/ripGrep.ts +++ b/packages/core/src/tools/ripGrep.ts @@ -8,7 +8,8 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import { downloadRipGrep } from '@joshua.litt/get-ripgrep'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; import { BaseDeclarativeTool, BaseToolInvocation, @@ -22,7 +23,6 @@ import { makeRelative, shortenPath } from '../utils/paths.js'; import { getErrorMessage, isNodeError } from '../utils/errors.js'; import type { Config } from '../config/config.js'; import { fileExists } from '../utils/fileUtils.js'; -import { Storage } from '../config/storage.js'; import { GREP_TOOL_NAME } from './tool-names.js'; import { debugLogger } from '../utils/debugLogger.js'; import { @@ -39,73 +39,48 @@ import { RIP_GREP_DEFINITION } from './definitions/coreTools.js'; import { resolveToolDeclaration } from './definitions/resolver.js'; import { type GrepMatch, formatGrepResults } from './grep-utils.js'; -function getRgCandidateFilenames(): readonly string[] { - return process.platform === 'win32' ? ['rg.exe', 'rg'] : ['rg']; -} +const __dirname = path.dirname(fileURLToPath(import.meta.url)); -async function resolveExistingRgPath(): Promise { - const binDir = Storage.getGlobalBinDir(); - for (const fileName of getRgCandidateFilenames()) { - const candidatePath = path.join(binDir, fileName); - if (await fileExists(candidatePath)) { - return candidatePath; +export async function getRipgrepPath(): Promise { + const platform = os.platform(); + const arch = os.arch(); + + // Map to the correct bundled binary + const binName = `rg-${platform}-${arch}${platform === 'win32' ? '.exe' : ''}`; + + const candidatePaths = [ + // 1. SEA runtime layout: everything is flattened into the root dir + path.resolve(__dirname, 'vendor/ripgrep', binName), + // 2. Dev/Dist layout: packages/core/dist/tools/ripGrep.js -> packages/core/vendor/ripgrep + path.resolve(__dirname, '../../vendor/ripgrep', binName), + ]; + + for (const candidate of candidatePaths) { + if (await fileExists(candidate)) { + return candidate; } } + return null; } -let ripgrepAcquisitionPromise: Promise | null = null; /** - * Ensures a ripgrep binary is available. - * - * NOTE: - * - The Gemini CLI currently prefers a managed ripgrep binary downloaded - * into its global bin directory. - * - Even if ripgrep is available on the system PATH, it is intentionally - * not used at this time. - * - * Preference for system-installed ripgrep is blocked on: - * - checksum verification of external binaries - * - internalization of the get-ripgrep dependency - * - * See: - * - feat(core): Prefer rg in system path (#11847) - * - Move get-ripgrep to third_party (#12099) - */ -async function ensureRipgrepAvailable(): Promise { - const existingPath = await resolveExistingRgPath(); - if (existingPath) { - return existingPath; - } - if (!ripgrepAcquisitionPromise) { - ripgrepAcquisitionPromise = (async () => { - try { - await downloadRipGrep(Storage.getGlobalBinDir()); - return await resolveExistingRgPath(); - } finally { - ripgrepAcquisitionPromise = null; - } - })(); - } - return ripgrepAcquisitionPromise; -} - -/** - * Checks if `rg` exists, if not then attempt to download it. + * Checks if `rg` exists in the bundled vendor directory. */ export async function canUseRipgrep(): Promise { - return (await ensureRipgrepAvailable()) !== null; + const binPath = await getRipgrepPath(); + return binPath !== null; } /** - * Ensures `rg` is downloaded, or throws. + * Ensures `rg` is available, or throws. */ export async function ensureRgPath(): Promise { - const downloadedPath = await ensureRipgrepAvailable(); - if (downloadedPath) { - return downloadedPath; + const binPath = await getRipgrepPath(); + if (binPath !== null) { + return binPath; } - throw new Error('Cannot use ripgrep.'); + throw new Error(`Cannot find bundled ripgrep binary.`); } /** diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 44f0c85316..ad90423686 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -45,6 +45,7 @@ import { } from '../utils/shell-utils.js'; import { SHELL_TOOL_NAME } from './tool-names.js'; import { PARAM_ADDITIONAL_PERMISSIONS } from './definitions/base-declarations.js'; +import { ApprovalMode } from '../policy/types.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { getShellDefinition } from './definitions/coreTools.js'; import { resolveToolDeclaration } from './definitions/resolver.js'; @@ -249,6 +250,10 @@ export class ShellToolInvocation extends BaseToolInvocation< abortSignal: AbortSignal, forcedDecision?: ForcedToolDecision, ): Promise { + if (this.context.config.getApprovalMode() === ApprovalMode.YOLO) { + return super.shouldConfirmExecute(abortSignal, forcedDecision); + } + if (this.params[PARAM_ADDITIONAL_PERMISSIONS]) { return this.getConfirmationDetails(abortSignal); } diff --git a/packages/core/src/tools/tool-error.ts b/packages/core/src/tools/tool-error.ts index 3ab221404a..260d7e2bf0 100644 --- a/packages/core/src/tools/tool-error.ts +++ b/packages/core/src/tools/tool-error.ts @@ -55,6 +55,7 @@ export enum ToolErrorType { // MCP-specific Errors MCP_TOOL_ERROR = 'mcp_tool_error', + MCP_RESOURCE_NOT_FOUND = 'mcp_resource_not_found', // Memory-specific Errors MEMORY_TOOL_EXECUTION_ERROR = 'memory_tool_execution_error', diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index faaa90f076..f8337fcf1d 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -79,6 +79,8 @@ import { UPDATE_TOPIC_DISPLAY_NAME, COMPLETE_TASK_TOOL_NAME, COMPLETE_TASK_DISPLAY_NAME, + READ_MCP_RESOURCE_TOOL_NAME, + LIST_MCP_RESOURCES_TOOL_NAME, TOPIC_PARAM_TITLE, TOPIC_PARAM_SUMMARY, TOPIC_PARAM_STRATEGIC_INTENT, @@ -106,6 +108,8 @@ export { UPDATE_TOPIC_DISPLAY_NAME, COMPLETE_TASK_TOOL_NAME, COMPLETE_TASK_DISPLAY_NAME, + READ_MCP_RESOURCE_TOOL_NAME, + LIST_MCP_RESOURCES_TOOL_NAME, // Shared parameter names PARAM_FILE_PATH, PARAM_DIR_PATH, @@ -272,6 +276,8 @@ export const ALL_BUILTIN_TOOL_NAMES = [ UPDATE_TOPIC_TOOL_NAME, COMPLETE_TASK_TOOL_NAME, AGENT_TOOL_NAME, + READ_MCP_RESOURCE_TOOL_NAME, + LIST_MCP_RESOURCES_TOOL_NAME, ] as const; /** @@ -291,6 +297,8 @@ export const PLAN_MODE_TOOLS = [ UPDATE_TOPIC_TOOL_NAME, 'codebase_investigator', 'cli_help', + READ_MCP_RESOURCE_TOOL_NAME, + LIST_MCP_RESOURCES_TOOL_NAME, ] as const; /** diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index c4c194620f..ea21a5dc3e 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -34,6 +34,8 @@ import { UPDATE_TOPIC_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME, + READ_MCP_RESOURCE_TOOL_NAME, + LIST_MCP_RESOURCES_TOOL_NAME, } from './tool-names.js'; type ToolParams = Record; @@ -602,6 +604,16 @@ export class ToolRegistry { } } + if ( + tool.name === READ_MCP_RESOURCE_TOOL_NAME || + tool.name === LIST_MCP_RESOURCES_TOOL_NAME + ) { + const mcpManager = this.config.getMcpClientManager(); + if (!mcpManager || mcpManager.getAllResources().length === 0) { + return false; + } + } + const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN; if ( (tool.name === ENTER_PLAN_MODE_TOOL_NAME && isPlanMode) || diff --git a/packages/core/src/utils/retry.test.ts b/packages/core/src/utils/retry.test.ts index a5b5a8b657..29758e6e92 100644 --- a/packages/core/src/utils/retry.test.ts +++ b/packages/core/src/utils/retry.test.ts @@ -511,6 +511,40 @@ describe('retryWithBackoff', () => { expect(mockFn).toHaveBeenCalledTimes(2); }); + it('should retry on OpenSSL 3.x SSL error code (ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC)', async () => { + const error = new Error('SSL error'); + (error as any).code = 'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC'; + const mockFn = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValue('success'); + + const promise = retryWithBackoff(mockFn, { + initialDelayMs: 1, + maxDelayMs: 1, + }); + await vi.runAllTimersAsync(); + await expect(promise).resolves.toBe('success'); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should retry on unknown SSL BAD_RECORD_MAC variant via substring fallback', async () => { + const error = new Error('SSL error'); + (error as any).code = 'ERR_SSL_SOME_FUTURE_BAD_RECORD_MAC'; + const mockFn = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValue('success'); + + const promise = retryWithBackoff(mockFn, { + initialDelayMs: 1, + maxDelayMs: 1, + }); + await vi.runAllTimersAsync(); + await expect(promise).resolves.toBe('success'); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + it('should retry on gaxios-style SSL error with code property', async () => { // This matches the exact structure from issue #17318 const error = new Error( diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index 46765216b9..5b3ac4f113 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -53,14 +53,30 @@ const RETRYABLE_NETWORK_CODES = [ 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', - // SSL/TLS transient errors - 'ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC', 'ERR_SSL_WRONG_VERSION_NUMBER', - 'ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC', - 'ERR_SSL_BAD_RECORD_MAC', 'EPROTO', // Generic protocol error (often SSL-related) ]; +// Node.js builds SSL error codes by prepending ERR_SSL_ to the uppercased +// OpenSSL reason string with spaces replaced by underscores (see +// TLSWrap::ClearOut in node/src/crypto/crypto_tls.cc). The reason string +// format varies by OpenSSL version (e.g. ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC +// on OpenSSL 1.x, ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC on OpenSSL 3.x), so +// match the stable suffix instead of enumerating every variant. +const RETRYABLE_SSL_ERROR_PATTERN = /^ERR_SSL_.*BAD_RECORD_MAC/i; + +/** + * Returns true if the error code should be retried: either an exact match + * against RETRYABLE_NETWORK_CODES, or an SSL BAD_RECORD_MAC variant (the + * OpenSSL reason-string portion of the code varies across OpenSSL versions). + */ +function isRetryableSslErrorCode(code: string): boolean { + return ( + RETRYABLE_NETWORK_CODES.includes(code) || + RETRYABLE_SSL_ERROR_PATTERN.test(code) + ); +} + function getNetworkErrorCode(error: unknown): string | undefined { const getCode = (obj: unknown): string | undefined => { if (typeof obj !== 'object' || obj === null) { @@ -112,7 +128,7 @@ export function getRetryErrorType(error: unknown): string { } const errorCode = getNetworkErrorCode(error); - if (errorCode && RETRYABLE_NETWORK_CODES.includes(errorCode)) { + if (errorCode && isRetryableSslErrorCode(errorCode)) { return errorCode; } @@ -153,7 +169,7 @@ export function isRetryableError( ): boolean { // Check for common network error codes const errorCode = getNetworkErrorCode(error); - if (errorCode && RETRYABLE_NETWORK_CODES.includes(errorCode)) { + if (errorCode && isRetryableSslErrorCode(errorCode)) { return true; } diff --git a/packages/core/vendor/ripgrep/rg-darwin-arm64 b/packages/core/vendor/ripgrep/rg-darwin-arm64 new file mode 100755 index 0000000000..e163565822 Binary files /dev/null and b/packages/core/vendor/ripgrep/rg-darwin-arm64 differ diff --git a/packages/core/vendor/ripgrep/rg-darwin-x64 b/packages/core/vendor/ripgrep/rg-darwin-x64 new file mode 100755 index 0000000000..ef047368a7 Binary files /dev/null and b/packages/core/vendor/ripgrep/rg-darwin-x64 differ diff --git a/packages/core/vendor/ripgrep/rg-linux-arm64 b/packages/core/vendor/ripgrep/rg-linux-arm64 new file mode 100755 index 0000000000..38c7ec9ae0 Binary files /dev/null and b/packages/core/vendor/ripgrep/rg-linux-arm64 differ diff --git a/packages/core/vendor/ripgrep/rg-linux-x64 b/packages/core/vendor/ripgrep/rg-linux-x64 new file mode 100755 index 0000000000..acf3d8ef76 Binary files /dev/null and b/packages/core/vendor/ripgrep/rg-linux-x64 differ diff --git a/packages/core/vendor/ripgrep/rg-win32-x64.exe b/packages/core/vendor/ripgrep/rg-win32-x64.exe new file mode 100644 index 0000000000..bd0e08ee46 Binary files /dev/null and b/packages/core/vendor/ripgrep/rg-win32-x64.exe differ diff --git a/packages/devtools/package.json b/packages/devtools/package.json index e05893fbe4..2e0aa2adb4 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-devtools", - "version": "0.39.0-nightly.20260408.e77b22e63", + "version": "0.40.0-nightly.20260414.g5b1f7375a", "license": "Apache-2.0", "type": "module", "main": "dist/src/index.js", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index dcff365bbf..767de96ce5 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-sdk", - "version": "0.39.0-nightly.20260408.e77b22e63", + "version": "0.40.0-nightly.20260414.g5b1f7375a", "description": "Gemini CLI SDK", "license": "Apache-2.0", "repository": { diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 613890f54a..3caeeffb11 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-test-utils", - "version": "0.39.0-nightly.20260408.e77b22e63", + "version": "0.40.0-nightly.20260414.g5b1f7375a", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/test-utils/src/perf-test-harness.ts b/packages/test-utils/src/perf-test-harness.ts index 2f376f58b6..f0520ccecb 100644 --- a/packages/test-utils/src/perf-test-harness.ts +++ b/packages/test-utils/src/perf-test-harness.ts @@ -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); diff --git a/packages/test-utils/src/test-rig.ts b/packages/test-utils/src/test-rig.ts index 734c1b9546..906a7760bf 100644 --- a/packages/test-utils/src/test-rig.ts +++ b/packages/test-utils/src/test-rig.ts @@ -193,6 +193,28 @@ export function checkModelOutputContent( return isValid; } +export interface MetricDataPoint { + attributes?: Record; + 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 | null { + readMetric(metricName: string): TelemetryMetric | null { const logs = this._readAndParseTelemetryLog(); for (const logData of logs) { if (logData.scopeMetrics) { diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 62f40323de..e71cbf0289 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -2,7 +2,7 @@ "name": "gemini-cli-vscode-ide-companion", "displayName": "Gemini CLI Companion", "description": "Enable Gemini CLI with direct access to your IDE workspace.", - "version": "0.39.0-nightly.20260408.e77b22e63", + "version": "0.40.0-nightly.20260414.g5b1f7375a", "publisher": "google", "icon": "assets/icon.png", "repository": { diff --git a/perf-tests/baselines.json b/perf-tests/baselines.json index d6972342d4..caf92bedb6 100644 --- a/perf-tests/baselines.json +++ b/perf-tests/baselines.json @@ -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" } } } diff --git a/perf-tests/perf-usage.test.ts b/perf-tests/perf-usage.test.ts index 4bbc5ab0ea..a100382f48 100644 --- a/perf-tests/perf-usage.test.ts +++ b/perf-tests/perf-usage.test.ts @@ -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); + } + }); + }); }); diff --git a/perf-tests/perf.long-chat.responses b/perf-tests/perf.long-chat.responses new file mode 100644 index 0000000000..7cf057e5a4 --- /dev/null +++ b/perf-tests/perf.long-chat.responses @@ -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}} diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 98bc786410..c285846cf6 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -92,12 +92,20 @@ "type": "boolean" }, "enableNotifications": { - "title": "Enable Notifications", - "description": "Enable run-event notifications for action-required prompts and session completion.", - "markdownDescription": "Enable run-event notifications for action-required prompts and session completion.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", + "title": "Enable Terminal Notifications", + "description": "Enable terminal run-event notifications for action-required prompts and session completion.", + "markdownDescription": "Enable terminal run-event notifications for action-required prompts and session completion.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", "default": false, "type": "boolean" }, + "notificationMethod": { + "title": "Terminal Notification Method", + "description": "How to send terminal notifications.", + "markdownDescription": "How to send terminal notifications.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `auto`", + "default": "auto", + "type": "string", + "enum": ["auto", "osc9", "osc777", "bell"] + }, "checkpointing": { "title": "Checkpointing", "description": "Session checkpointing settings.", diff --git a/scripts/copy_bundle_assets.js b/scripts/copy_bundle_assets.js index ef6a68e58d..f1ea297ba9 100644 --- a/scripts/copy_bundle_assets.js +++ b/scripts/copy_bundle_assets.js @@ -108,4 +108,33 @@ if (!existsSync(bundleMcpSrc)) { cpSync(bundleMcpSrc, bundleMcpDest, { recursive: true, dereference: true }); console.log('Copied bundled chrome-devtools-mcp to bundle/bundled/'); +// 7. Copy pre-built ripgrep vendor binaries +const ripgrepVendorSrc = join(root, 'packages/core/vendor/ripgrep'); +const ripgrepVendorDest = join(bundleDir, 'vendor', 'ripgrep'); +if (existsSync(ripgrepVendorSrc)) { + mkdirSync(ripgrepVendorDest, { recursive: true }); + cpSync(ripgrepVendorSrc, ripgrepVendorDest, { + recursive: true, + dereference: true, + }); + console.log('Copied ripgrep vendor binaries to bundle/vendor/ripgrep/'); +} + +// 8. Copy Extension Examples +const extensionExamplesSrc = join( + root, + 'packages/cli/src/commands/extensions/examples', +); +const extensionExamplesDest = join(bundleDir, 'examples'); +const EXCLUDED_EXAMPLE_DIRS = ['node_modules', 'dist']; + +if (existsSync(extensionExamplesSrc)) { + cpSync(extensionExamplesSrc, extensionExamplesDest, { + recursive: true, + dereference: true, + filter: (src) => !EXCLUDED_EXAMPLE_DIRS.some((dir) => src.includes(dir)), + }); + console.log('Copied extension examples to bundle/examples/'); +} + console.log('Assets copied to bundle/'); diff --git a/scripts/download-ripgrep-binaries.ts b/scripts/download-ripgrep-binaries.ts new file mode 100644 index 0000000000..969d69c7eb --- /dev/null +++ b/scripts/download-ripgrep-binaries.ts @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview This script downloads pre-built ripgrep binaries for all supported + * architectures and platforms. These binaries are checked into the repository + * under packages/core/vendor/ripgrep. + * + * Maintainers should periodically run this script to upgrade the version + * of ripgrep being distributed. + * + * Usage: npx tsx scripts/download-ripgrep-binaries.ts + */ + +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { fileURLToPath } from 'node:url'; +import { createWriteStream } from 'node:fs'; +import { Readable } from 'node:stream'; +import type { ReadableStream } from 'node:stream/web'; +import { execFileSync } from 'node:child_process'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CORE_VENDOR_DIR = path.join(__dirname, '../packages/core/vendor/ripgrep'); +const VERSION = 'v13.0.0-10'; + +interface Target { + platform: string; + arch: string; + file: string; +} + +const targets: Target[] = [ + { platform: 'darwin', arch: 'arm64', file: 'aarch64-apple-darwin.tar.gz' }, + { platform: 'darwin', arch: 'x64', file: 'x86_64-apple-darwin.tar.gz' }, + { + platform: 'linux', + arch: 'arm64', + file: 'aarch64-unknown-linux-gnu.tar.gz', + }, + { platform: 'linux', arch: 'x64', file: 'x86_64-unknown-linux-musl.tar.gz' }, + { platform: 'win32', arch: 'x64', file: 'x86_64-pc-windows-msvc.zip' }, +]; + +async function downloadBinary() { + await fsPromises.mkdir(CORE_VENDOR_DIR, { recursive: true }); + + for (const target of targets) { + const url = `https://github.com/microsoft/ripgrep-prebuilt/releases/download/${VERSION}/ripgrep-${VERSION}-${target.file}`; + const archivePath = path.join(CORE_VENDOR_DIR, target.file); + const binName = `rg-${target.platform}-${target.arch}${target.platform === 'win32' ? '.exe' : ''}`; + const finalBinPath = path.join(CORE_VENDOR_DIR, binName); + + if (fs.existsSync(finalBinPath)) { + console.log(`[Cache] ${binName} already exists.`); + continue; + } + + console.log(`[Download] ${url} -> ${archivePath}`); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.statusText}`); + } + + if (!response.body) { + throw new Error(`Response body is null for ${url}`); + } + + const fileStream = createWriteStream(archivePath); + + // Node 18+ global fetch response.body is a ReadableStream (web stream) + // pipeline(Readable.fromWeb(response.body), fileStream) works in Node 18+ + await pipeline( + Readable.fromWeb(response.body as ReadableStream), + fileStream, + ); + + console.log(`[Extract] Extracting ${archivePath}...`); + // Extract using shell commands for simplicity + if (target.file.endsWith('.tar.gz')) { + execFileSync('tar', ['-xzf', archivePath, '-C', CORE_VENDOR_DIR]); + // Microsoft's ripgrep release extracts directly to `rg` inside the current directory sometimes + const sourceBin = path.join(CORE_VENDOR_DIR, 'rg'); + if (fs.existsSync(sourceBin)) { + await fsPromises.rename(sourceBin, finalBinPath); + } else { + // Fallback for sub-directory if it happens + const extractedDirName = `ripgrep-${VERSION}-${target.file.replace('.tar.gz', '')}`; + const fallbackSourceBin = path.join( + CORE_VENDOR_DIR, + extractedDirName, + 'rg', + ); + if (fs.existsSync(fallbackSourceBin)) { + await fsPromises.rename(fallbackSourceBin, finalBinPath); + await fsPromises.rm(path.join(CORE_VENDOR_DIR, extractedDirName), { + recursive: true, + force: true, + }); + } else { + throw new Error( + `Could not find extracted 'rg' binary for ${target.platform} ${target.arch}`, + ); + } + } + } else if (target.file.endsWith('.zip')) { + execFileSync('unzip', ['-o', '-q', archivePath, '-d', CORE_VENDOR_DIR]); + const sourceBin = path.join(CORE_VENDOR_DIR, 'rg.exe'); + if (fs.existsSync(sourceBin)) { + await fsPromises.rename(sourceBin, finalBinPath); + } else { + const extractedDirName = `ripgrep-${VERSION}-${target.file.replace('.zip', '')}`; + const fallbackSourceBin = path.join( + CORE_VENDOR_DIR, + extractedDirName, + 'rg.exe', + ); + if (fs.existsSync(fallbackSourceBin)) { + await fsPromises.rename(fallbackSourceBin, finalBinPath); + await fsPromises.rm(path.join(CORE_VENDOR_DIR, extractedDirName), { + recursive: true, + force: true, + }); + } else { + throw new Error( + `Could not find extracted 'rg.exe' binary for ${target.platform} ${target.arch}`, + ); + } + } + } + + // Clean up archive + await fsPromises.unlink(archivePath); + console.log(`[Success] Saved to ${finalBinPath}`); + } +} + +downloadBinary().catch((err) => { + console.error(err); + process.exit(1); +});