diff --git a/.github/workflows/gemini-scheduled-stale-pr-closer.yml b/.github/workflows/gemini-scheduled-stale-pr-closer.yml index 90d7417b055..bd7fd0ddc9a 100644 --- a/.github/workflows/gemini-scheduled-stale-pr-closer.yml +++ b/.github/workflows/gemini-scheduled-stale-pr-closer.yml @@ -43,23 +43,56 @@ jobs: // 1. Fetch maintainers for verification let maintainerLogins = new Set(); - let teamFetchSucceeded = false; - try { - const members = await github.paginate(github.rest.teams.listMembersInOrg, { - org: context.repo.owner, - team_slug: 'gemini-cli-maintainers' - }); - maintainerLogins = new Set(members.map(m => m.login.toLowerCase())); - teamFetchSucceeded = true; - core.info(`Successfully fetched ${maintainerLogins.size} team members from gemini-cli-maintainers`); - } catch (e) { - core.warning(`Failed to fetch team members from gemini-cli-maintainers: ${e.message}. Falling back to author_association only.`); + const teams = ['gemini-cli-maintainers', 'gemini-cli-askmode-approvers', 'gemini-cli-docs']; + + for (const team_slug of teams) { + try { + const members = await github.paginate(github.rest.teams.listMembersInOrg, { + org: context.repo.owner, + team_slug: team_slug + }); + for (const m of members) maintainerLogins.add(m.login.toLowerCase()); + core.info(`Successfully fetched ${members.length} team members from ${team_slug}`); + } catch (e) { + core.warning(`Failed to fetch team members from ${team_slug}: ${e.message}`); + } } - const isMaintainer = (login, assoc) => { + const isGooglerCache = new Map(); + const isGoogler = async (login) => { + if (isGooglerCache.has(login)) return isGooglerCache.get(login); + + try { + // Check membership in 'googlers' or 'google' orgs + const orgs = ['googlers', 'google']; + for (const org of orgs) { + try { + await github.rest.orgs.checkMembershipForUser({ + org: org, + username: login + }); + core.info(`User ${login} is a member of ${org} organization.`); + isGooglerCache.set(login, true); + return true; + } catch (e) { + // 404 just means they aren't a member, which is fine + if (e.status !== 404) throw e; + } + } + } catch (e) { + core.warning(`Failed to check org membership for ${login}: ${e.message}`); + } + + isGooglerCache.set(login, false); + return false; + }; + + const isMaintainer = async (login, assoc) => { const isTeamMember = maintainerLogins.has(login.toLowerCase()); const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc); - return isTeamMember || isRepoMaintainer; + if (isTeamMember || isRepoMaintainer) return true; + + return await isGoogler(login); }; // 2. Determine which PRs to check @@ -81,7 +114,7 @@ jobs: } for (const pr of prs) { - const maintainerPr = isMaintainer(pr.user.login, pr.author_association); + const maintainerPr = await isMaintainer(pr.user.login, pr.author_association); const isBot = pr.user.type === 'Bot' || pr.user.login.endsWith('[bot]'); // Detection Logic for Linked Issues @@ -175,7 +208,7 @@ jobs: pull_number: pr.number }); for (const r of reviews) { - if (isMaintainer(r.user.login, r.author_association)) { + if (await isMaintainer(r.user.login, r.author_association)) { const d = new Date(r.submitted_at || r.updated_at); if (d > lastActivity) lastActivity = d; } @@ -186,7 +219,7 @@ jobs: issue_number: pr.number }); for (const c of comments) { - if (isMaintainer(c.user.login, c.author_association)) { + if (await isMaintainer(c.user.login, c.author_association)) { const d = new Date(c.updated_at); if (d > lastActivity) lastActivity = d; } diff --git a/.github/workflows/pr-contribution-guidelines-notifier.yml b/.github/workflows/pr-contribution-guidelines-notifier.yml index fdabd20f3d2..26585203710 100644 --- a/.github/workflows/pr-contribution-guidelines-notifier.yml +++ b/.github/workflows/pr-contribution-guidelines-notifier.yml @@ -35,9 +35,31 @@ jobs: const pr_number = context.payload.pull_request.number; // 1. Check if the PR author is a maintainer + const isGoogler = async (login) => { + try { + const orgs = ['googlers', 'google']; + for (const org of orgs) { + try { + await github.rest.orgs.checkMembershipForUser({ + org: org, + username: login + }); + return true; + } catch (e) { + if (e.status !== 404) throw e; + } + } + } catch (e) { + core.warning(`Failed to check org membership for ${login}: ${e.message}`); + } + return false; + }; + const authorAssociation = context.payload.pull_request.author_association; - if (['OWNER', 'MEMBER', 'COLLABORATOR'].includes(authorAssociation)) { - core.info(`${username} is a maintainer (Association: ${authorAssociation}). No notification needed.`); + const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(authorAssociation); + + if (isRepoMaintainer || await isGoogler(username)) { + core.info(`${username} is a maintainer or Googler. No notification needed.`); return; } diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md index 98e290c30d3..013ee3281cf 100644 --- a/docs/changelogs/index.md +++ b/docs/changelogs/index.md @@ -18,6 +18,26 @@ on GitHub. | [Preview](preview.md) | Experimental features ready for early feedback. | | [Stable](latest.md) | Stable, recommended for general use. | +## Announcements: v0.28.0 - 2026-02-03 + +- **Slash Command:** We've added a new `/prompt-suggest` slash command to help + you generate prompt suggestions + ([#17264](https://github.com/google-gemini/gemini-cli/pull/17264) by + @NTaylorMullen). +- **IDE Support:** Gemini CLI now supports the Positron IDE + ([#15047](https://github.com/google-gemini/gemini-cli/pull/15047) by + @kapsner). +- **Customization:** You can now use custom themes in extensions, and we've + implemented automatic theme switching based on your terminal's background + ([#17327](https://github.com/google-gemini/gemini-cli/pull/17327) by + @spencer426, [#17976](https://github.com/google-gemini/gemini-cli/pull/17976) + by @Abhijit-2592). +- **Authentication:** We've added interactive and non-interactive consent for + OAuth, and you can now include your auth method in bug reports + ([#17699](https://github.com/google-gemini/gemini-cli/pull/17699) by + @ehedlund, [#17569](https://github.com/google-gemini/gemini-cli/pull/17569) by + @erikus). + ## Announcements: v0.27.0 - 2026-02-03 - **Event-Driven Architecture:** The CLI now uses a new event-driven scheduler diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md index ce0a0fdfff2..6ba7b88e1c2 100644 --- a/docs/changelogs/latest.md +++ b/docs/changelogs/latest.md @@ -1,6 +1,6 @@ -# Latest stable release: v0.27.0 +# Latest stable release: v0.28.0 -Released: February 3, 2026 +Released: February 10, 2026 For most users, our latest stable release is the recommended release. Install the latest stable version with: @@ -11,437 +11,305 @@ npm install -g @google/gemini-cli ## Highlights -- **Event-Driven Architecture:** The CLI now uses an event-driven scheduler for - tool execution, improving performance and responsiveness. This includes - migrating non-interactive flows and sub-agents to the new scheduler. -- **Enhanced User Experience:** This release introduces several UI/UX - improvements, including queued tool confirmations and the ability to expand - and collapse large pasted text blocks. The `Settings` dialog has been improved - to reduce jitter and preserve focus. -- **Agent and Skill Improvements:** Agent Skills have been promoted to a stable - feature. Sub-agents now use a JSON schema for input and are tracked by an - `AgentRegistry`. -- **New `/rewind` Command:** A new `/rewind` command has been implemented to - allow users to go back in their session history. -- **Improved Shell and File Handling:** The shell tool's output format has been - optimized, and the CLI now gracefully handles disk-full errors during chat - recording. A bug in detecting already added paths has been fixed. -- **Linux Clipboard Support:** Image pasting capabilities for Wayland and X11 on - Linux have been added. +- **Commands & UX Enhancements:** Introduced `/prompt-suggest` command, + alongside updated undo/redo keybindings and automatic theme switching. +- **Expanded IDE Support:** Now offering compatibility with Positron IDE, + expanding integration options for developers. +- **Enhanced Security & Authentication:** Implemented interactive and + non-interactive OAuth consent, improving both security and diagnostic + capabilities for bug reports. +- **Advanced Planning & Agent Tools:** Integrated a generic Checklist component + for structured task management and evolved subagent capabilities with dynamic + policy registration. +- **Improved Core Stability & Reliability:** Resolved critical environment + loading, authentication, and session management issues, ensuring a more robust + experience. +- **Background Shell Commands:** Enabled the execution of shell commands in the + background for increased workflow efficiency. ## What's Changed -- remove fireAgent and beforeAgent hook by @ishaanxgupta in - [#16919](https://github.com/google-gemini/gemini-cli/pull/16919) -- Remove unused modelHooks and toolHooks by @ved015 in - [#17115](https://github.com/google-gemini/gemini-cli/pull/17115) -- feat(cli): sanitize ANSI escape sequences in non-interactive output by - @sehoon38 in [#17172](https://github.com/google-gemini/gemini-cli/pull/17172) -- Update Attempt text to Retry when showing the retry happening to the … by - @sehoon38 in [#17178](https://github.com/google-gemini/gemini-cli/pull/17178) -- chore(skills): update pr-creator skill workflow by @sehoon38 in - [#17180](https://github.com/google-gemini/gemini-cli/pull/17180) -- feat(cli): implement event-driven tool execution scheduler by @abhipatel12 in - [#17078](https://github.com/google-gemini/gemini-cli/pull/17078) -- chore(release): bump version to 0.27.0-nightly.20260121.97aac696f by +- feat(commands): add /prompt-suggest slash command by @NTaylorMullen in + [#17264](https://github.com/google-gemini/gemini-cli/pull/17264) +- feat(cli): align hooks enable/disable with skills and improve completion by + @sehoon38 in [#16822](https://github.com/google-gemini/gemini-cli/pull/16822) +- docs: add CLI reference documentation by @leochiu-a in + [#17504](https://github.com/google-gemini/gemini-cli/pull/17504) +- chore(release): bump version to 0.28.0-nightly.20260128.adc8e11bb by @gemini-cli-robot in - [#17181](https://github.com/google-gemini/gemini-cli/pull/17181) -- Remove other rewind reference in docs by @chrstnb in - [#17149](https://github.com/google-gemini/gemini-cli/pull/17149) -- feat(skills): add code-reviewer skill by @sehoon38 in - [#17187](https://github.com/google-gemini/gemini-cli/pull/17187) -- feat(plan): Extend Shift+Tab Mode Cycling to include Plan Mode by @Adib234 in - [#17177](https://github.com/google-gemini/gemini-cli/pull/17177) -- feat(plan): refactor TestRig and eval helper to support configurable approval - modes by @jerop in - [#17171](https://github.com/google-gemini/gemini-cli/pull/17171) -- feat(workflows): support recursive workstream labeling and new IDs by - @bdmorgan in [#17207](https://github.com/google-gemini/gemini-cli/pull/17207) -- Run evals for all models. by @gundermanc in - [#17123](https://github.com/google-gemini/gemini-cli/pull/17123) -- fix(github): improve label-workstream-rollup efficiency with GraphQL by - @bdmorgan in [#17217](https://github.com/google-gemini/gemini-cli/pull/17217) -- Docs: Update changelogs for v.0.25.0 and v0.26.0-preview.0 releases. by - @g-samroberts in - [#17215](https://github.com/google-gemini/gemini-cli/pull/17215) -- Migrate beforeTool and afterTool hooks to hookSystem by @ved015 in - [#17204](https://github.com/google-gemini/gemini-cli/pull/17204) -- fix(github): improve label-workstream-rollup efficiency and fix bugs by - @bdmorgan in [#17219](https://github.com/google-gemini/gemini-cli/pull/17219) -- feat(cli): improve skill enablement/disablement verbiage by @NTaylorMullen in - [#17192](https://github.com/google-gemini/gemini-cli/pull/17192) -- fix(admin): Ensure CLI commands run in non-interactive mode by @skeshive in - [#17218](https://github.com/google-gemini/gemini-cli/pull/17218) -- feat(core): support dynamic variable substitution in system prompt override by - @NTaylorMullen in - [#17042](https://github.com/google-gemini/gemini-cli/pull/17042) -- fix(core,cli): enable recursive directory access for by @galz10 in - [#17094](https://github.com/google-gemini/gemini-cli/pull/17094) -- Docs: Marking for experimental features by @jkcinouye in - [#16760](https://github.com/google-gemini/gemini-cli/pull/16760) -- Support command/ctrl/alt backspace correctly by @scidomino in - [#17175](https://github.com/google-gemini/gemini-cli/pull/17175) -- feat(plan): add approval mode instructions to system prompt by @jerop in - [#17151](https://github.com/google-gemini/gemini-cli/pull/17151) -- feat(core): enable disableLLMCorrection by default by @SandyTao520 in - [#17223](https://github.com/google-gemini/gemini-cli/pull/17223) -- Remove unused slug from sidebar by @chrstnb in - [#17229](https://github.com/google-gemini/gemini-cli/pull/17229) -- drain stdin on exit by @scidomino in - [#17241](https://github.com/google-gemini/gemini-cli/pull/17241) -- refactor(cli): decouple UI from live tool execution via ToolActionsContext by - @abhipatel12 in - [#17183](https://github.com/google-gemini/gemini-cli/pull/17183) -- fix(core): update token count and telemetry on /chat resume history load by - @psinha40898 in - [#16279](https://github.com/google-gemini/gemini-cli/pull/16279) -- fix: /policy to display policies according to mode by @ishaanxgupta in - [#16772](https://github.com/google-gemini/gemini-cli/pull/16772) -- fix(core): simplify replace tool error message by @SandyTao520 in - [#17246](https://github.com/google-gemini/gemini-cli/pull/17246) -- feat(cli): consolidate shell inactivity and redirection monitoring by - @NTaylorMullen in - [#17086](https://github.com/google-gemini/gemini-cli/pull/17086) -- fix(scheduler): prevent stale tool re-publication and fix stuck UI state by + [#17725](https://github.com/google-gemini/gemini-cli/pull/17725) +- feat(skills): final stable promotion cleanup by @abhipatel12 in + [#17726](https://github.com/google-gemini/gemini-cli/pull/17726) +- test(core): mock fetch in OAuth transport fallback tests by @jw409 in + [#17059](https://github.com/google-gemini/gemini-cli/pull/17059) +- feat(cli): include auth method in /bug by @erikus in + [#17569](https://github.com/google-gemini/gemini-cli/pull/17569) +- Add a email privacy note to bug_report template by @nemyung in + [#17474](https://github.com/google-gemini/gemini-cli/pull/17474) +- Rewind documentation by @Adib234 in + [#17446](https://github.com/google-gemini/gemini-cli/pull/17446) +- fix: verify audio/video MIME types with content check by @maru0804 in + [#16907](https://github.com/google-gemini/gemini-cli/pull/16907) +- feat(core): add support for positron ide + ([#15045](https://github.com/google-gemini/gemini-cli/pull/15045)) by @kapsner + in [#15047](https://github.com/google-gemini/gemini-cli/pull/15047) +- /oncall dedup - wrap texts to nextlines by @sehoon38 in + [#17782](https://github.com/google-gemini/gemini-cli/pull/17782) +- fix(admin): rename advanced features admin setting by @skeshive in + [#17786](https://github.com/google-gemini/gemini-cli/pull/17786) +- [extension config] Make breaking optional value non-optional by @chrstnb in + [#17785](https://github.com/google-gemini/gemini-cli/pull/17785) +- Fix docs-writer skill issues by @g-samroberts in + [#17734](https://github.com/google-gemini/gemini-cli/pull/17734) +- fix(core): suppress duplicate hook failure warnings during streaming by @abhipatel12 in - [#17227](https://github.com/google-gemini/gemini-cli/pull/17227) -- feat(config): default enableEventDrivenScheduler to true by @abhipatel12 in - [#17211](https://github.com/google-gemini/gemini-cli/pull/17211) -- feat(hooks): enable hooks system by default by @abhipatel12 in - [#17247](https://github.com/google-gemini/gemini-cli/pull/17247) -- feat(core): Enable AgentRegistry to track all discovered subagents by + [#17727](https://github.com/google-gemini/gemini-cli/pull/17727) +- test: add more tests for AskUser by @jackwotherspoon in + [#17720](https://github.com/google-gemini/gemini-cli/pull/17720) +- feat(cli): enable activity logging for non-interactive mode and evals by @SandyTao520 in - [#17253](https://github.com/google-gemini/gemini-cli/pull/17253) -- feat(core): Have subagents use a JSON schema type for input. by @joshualitt in - [#17152](https://github.com/google-gemini/gemini-cli/pull/17152) -- feat: replace large text pastes with [Pasted Text: X lines] placeholder by - @jackwotherspoon in - [#16422](https://github.com/google-gemini/gemini-cli/pull/16422) -- security(hooks): Wrap hook-injected context in distinct XML tags by @yunaseoul - in [#17237](https://github.com/google-gemini/gemini-cli/pull/17237) -- Enable the ability to queue specific nightly eval tests by @gundermanc in - [#17262](https://github.com/google-gemini/gemini-cli/pull/17262) -- docs(hooks): comprehensive update of hook documentation and specs by + [#17703](https://github.com/google-gemini/gemini-cli/pull/17703) +- feat(core): add support for custom deny messages in policy rules by + @allenhutchison in + [#17427](https://github.com/google-gemini/gemini-cli/pull/17427) +- Fix unintended credential exposure to MCP Servers by @Adib234 in + [#17311](https://github.com/google-gemini/gemini-cli/pull/17311) +- feat(extensions): add support for custom themes in extensions by @spencer426 + in [#17327](https://github.com/google-gemini/gemini-cli/pull/17327) +- fix: persist and restore workspace directories on session resume by + @korade-krushna in + [#17454](https://github.com/google-gemini/gemini-cli/pull/17454) +- Update release notes pages for 0.26.0 and 0.27.0-preview. by @g-samroberts in + [#17744](https://github.com/google-gemini/gemini-cli/pull/17744) +- feat(ux): update cell border color and created test file for table rendering + by @devr0306 in + [#17798](https://github.com/google-gemini/gemini-cli/pull/17798) +- Change height for the ToolConfirmationQueue. by @jacob314 in + [#17799](https://github.com/google-gemini/gemini-cli/pull/17799) +- feat(cli): add user identity info to stats command by @sehoon38 in + [#17612](https://github.com/google-gemini/gemini-cli/pull/17612) +- fix(ux): fixed off-by-some wrapping caused by fixed-width characters by + @devr0306 in [#17816](https://github.com/google-gemini/gemini-cli/pull/17816) +- feat(cli): update undo/redo keybindings to Cmd+Z/Alt+Z and + Shift+Cmd+Z/Shift+Alt+Z by @scidomino in + [#17800](https://github.com/google-gemini/gemini-cli/pull/17800) +- fix(evals): use absolute path for activity log directory by @SandyTao520 in + [#17830](https://github.com/google-gemini/gemini-cli/pull/17830) +- test: add integration test to verify stdout/stderr routing by @ved015 in + [#17280](https://github.com/google-gemini/gemini-cli/pull/17280) +- fix(cli): list installed extensions when update target missing by @tt-a1i in + [#17082](https://github.com/google-gemini/gemini-cli/pull/17082) +- fix(cli): handle PAT tokens and credentials in git remote URL parsing by + @afarber in [#14650](https://github.com/google-gemini/gemini-cli/pull/14650) +- fix(core): use returnDisplay for error result display by @Nubebuster in + [#14994](https://github.com/google-gemini/gemini-cli/pull/14994) +- Fix detection of bun as package manager by @Randomblock1 in + [#17462](https://github.com/google-gemini/gemini-cli/pull/17462) +- feat(cli): show hooksConfig.enabled in settings dialog by @abhipatel12 in + [#17810](https://github.com/google-gemini/gemini-cli/pull/17810) +- feat(cli): Display user identity (auth, email, tier) on startup by @yunaseoul + in [#17591](https://github.com/google-gemini/gemini-cli/pull/17591) +- fix: prevent ghost border for AskUserDialog by @jackwotherspoon in + [#17788](https://github.com/google-gemini/gemini-cli/pull/17788) +- docs: mark A2A subagents as experimental in subagents.md by @adamfweidman in + [#17863](https://github.com/google-gemini/gemini-cli/pull/17863) +- Resolve error thrown for sensitive values by @chrstnb in + [#17826](https://github.com/google-gemini/gemini-cli/pull/17826) +- fix(admin): Rename secureModeEnabled to strictModeDisabled by @skeshive in + [#17789](https://github.com/google-gemini/gemini-cli/pull/17789) +- feat(ux): update truncate dots to be shorter in tables by @devr0306 in + [#17825](https://github.com/google-gemini/gemini-cli/pull/17825) +- fix(core): resolve DEP0040 punycode deprecation via patch-package by + @ATHARVA262005 in + [#17692](https://github.com/google-gemini/gemini-cli/pull/17692) +- feat(plan): create generic Checklist component and refactor Todo by @Adib234 + in [#17741](https://github.com/google-gemini/gemini-cli/pull/17741) +- Cleanup post delegate_to_agent removal by @gundermanc in + [#17875](https://github.com/google-gemini/gemini-cli/pull/17875) +- fix(core): use GIT_CONFIG_GLOBAL to isolate shadow git repo configuration - + Fixes [#17877](https://github.com/google-gemini/gemini-cli/pull/17877) by + @cocosheng-g in + [#17803](https://github.com/google-gemini/gemini-cli/pull/17803) +- Disable mouse tracking e2e by @alisa-alisa in + [#17880](https://github.com/google-gemini/gemini-cli/pull/17880) +- fix(cli): use correct setting key for Cloud Shell auth by @sehoon38 in + [#17884](https://github.com/google-gemini/gemini-cli/pull/17884) +- chore: revert IDE specific ASCII logo by @jackwotherspoon in + [#17887](https://github.com/google-gemini/gemini-cli/pull/17887) +- Revert "fix(core): resolve DEP0040 punycode deprecation via patch-package" by + @sehoon38 in [#17898](https://github.com/google-gemini/gemini-cli/pull/17898) +- Refactoring of disabling of mouse tracking in e2e tests by @alisa-alisa in + [#17902](https://github.com/google-gemini/gemini-cli/pull/17902) +- feat(core): Add GOOGLE_GENAI_API_VERSION environment variable support by + @deyim in [#16177](https://github.com/google-gemini/gemini-cli/pull/16177) +- feat(core): Isolate and cleanup truncated tool outputs by @SandyTao520 in + [#17594](https://github.com/google-gemini/gemini-cli/pull/17594) +- Create skills page, update commands, refine docs by @g-samroberts in + [#17842](https://github.com/google-gemini/gemini-cli/pull/17842) +- feat: preserve EOL in files by @Thomas-Shephard in + [#16087](https://github.com/google-gemini/gemini-cli/pull/16087) +- Fix HalfLinePaddedBox in screenreader mode. by @jacob314 in + [#17914](https://github.com/google-gemini/gemini-cli/pull/17914) +- bug(ux) vim mode fixes. Start in insert mode. Fix bug blocking F12 and ctrl-X + in vim mode. by @jacob314 in + [#17938](https://github.com/google-gemini/gemini-cli/pull/17938) +- feat(core): implement interactive and non-interactive consent for OAuth by + @ehedlund in [#17699](https://github.com/google-gemini/gemini-cli/pull/17699) +- perf(core): optimize token calculation and add support for multimodal tool + responses by @abhipatel12 in + [#17835](https://github.com/google-gemini/gemini-cli/pull/17835) +- refactor(hooks): remove legacy tools.enableHooks setting by @abhipatel12 in + [#17867](https://github.com/google-gemini/gemini-cli/pull/17867) +- feat(ci): add npx smoke test to verify installability by @bdmorgan in + [#17927](https://github.com/google-gemini/gemini-cli/pull/17927) +- feat(core): implement dynamic policy registration for subagents by @abhipatel12 in - [#16816](https://github.com/google-gemini/gemini-cli/pull/16816) -- refactor: improve large text paste placeholder by @jacob314 in - [#17269](https://github.com/google-gemini/gemini-cli/pull/17269) -- feat: implement /rewind command by @Adib234 in - [#15720](https://github.com/google-gemini/gemini-cli/pull/15720) -- Feature/jetbrains ide detection by @SoLoHiC in - [#16243](https://github.com/google-gemini/gemini-cli/pull/16243) -- docs: update typo in mcp-server.md file by @schifferl in - [#17099](https://github.com/google-gemini/gemini-cli/pull/17099) -- Sanitize command names and descriptions by @ehedlund in - [#17228](https://github.com/google-gemini/gemini-cli/pull/17228) -- fix(auth): don't crash when initial auth fails by @skeshive in - [#17308](https://github.com/google-gemini/gemini-cli/pull/17308) -- Added image pasting capabilities for Wayland and X11 on Linux by @devr0306 in - [#17144](https://github.com/google-gemini/gemini-cli/pull/17144) -- feat: add AskUser tool schema by @jackwotherspoon in - [#16988](https://github.com/google-gemini/gemini-cli/pull/16988) -- fix cli settings: resolve layout jitter in settings bar by @Mag1ck in - [#16256](https://github.com/google-gemini/gemini-cli/pull/16256) -- fix: show whitespace changes in edit tool diffs by @Ujjiyara in - [#17213](https://github.com/google-gemini/gemini-cli/pull/17213) -- Remove redundant calls setting linuxClipboardTool. getUserLinuxClipboardTool() - now handles the caching internally by @jacob314 in - [#17320](https://github.com/google-gemini/gemini-cli/pull/17320) -- ci: allow failure in evals-nightly run step by @gundermanc in - [#17319](https://github.com/google-gemini/gemini-cli/pull/17319) -- feat(cli): Add state management and plumbing for agent configuration dialog by - @SandyTao520 in - [#17259](https://github.com/google-gemini/gemini-cli/pull/17259) -- bug: fix ide-client connection to ide-companion when inside docker via - ssh/devcontainer by @kapsner in - [#15049](https://github.com/google-gemini/gemini-cli/pull/15049) -- Emit correct newline type return by @scidomino in - [#17331](https://github.com/google-gemini/gemini-cli/pull/17331) -- New skill: docs-writer by @g-samroberts in - [#17268](https://github.com/google-gemini/gemini-cli/pull/17268) -- fix(core): Resolve AbortSignal MaxListenersExceededWarning (#5950) by - @spencer426 in - [#16735](https://github.com/google-gemini/gemini-cli/pull/16735) -- Disable tips after 10 runs by @Adib234 in - [#17101](https://github.com/google-gemini/gemini-cli/pull/17101) -- Fix so rewind starts at the bottom and loadHistory refreshes static content. - by @jacob314 in - [#17335](https://github.com/google-gemini/gemini-cli/pull/17335) -- feat(core): Remove legacy settings. by @joshualitt in - [#17244](https://github.com/google-gemini/gemini-cli/pull/17244) -- feat(plan): add 'communicate' tool kind by @jerop in - [#17341](https://github.com/google-gemini/gemini-cli/pull/17341) -- feat(routing): A/B Test Numerical Complexity Scoring for Gemini 3 by - @mattKorwel in - [#16041](https://github.com/google-gemini/gemini-cli/pull/16041) -- feat(plan): update UI Theme for Plan Mode by @Adib234 in - [#17243](https://github.com/google-gemini/gemini-cli/pull/17243) -- fix(ui): stabilize rendering during terminal resize in alternate buffer by - @lkk214 in [#15783](https://github.com/google-gemini/gemini-cli/pull/15783) -- feat(cli): add /agents config command and improve agent discovery by + [#17838](https://github.com/google-gemini/gemini-cli/pull/17838) +- feat: Implement background shell commands by @galz10 in + [#14849](https://github.com/google-gemini/gemini-cli/pull/14849) +- feat(admin): provide actionable error messages for disabled features by + @skeshive in [#17815](https://github.com/google-gemini/gemini-cli/pull/17815) +- Fix bugs where Rewind and Resume showed Ugly and 100X too verbose content. by + @jacob314 in [#17940](https://github.com/google-gemini/gemini-cli/pull/17940) +- Fix broken link in docs by @chrstnb in + [#17959](https://github.com/google-gemini/gemini-cli/pull/17959) +- feat(plan): reuse standard tool confirmation for AskUser tool by @jerop in + [#17864](https://github.com/google-gemini/gemini-cli/pull/17864) +- feat(core): enable overriding CODE_ASSIST_API_VERSION with env var by + @lottielin in [#17942](https://github.com/google-gemini/gemini-cli/pull/17942) +- run npx pointing to the specific commit SHA by @sehoon38 in + [#17970](https://github.com/google-gemini/gemini-cli/pull/17970) +- Add allowedExtensions setting by @kevinjwang1 in + [#17695](https://github.com/google-gemini/gemini-cli/pull/17695) +- feat(plan): refactor ToolConfirmationPayload to union type by @jerop in + [#17980](https://github.com/google-gemini/gemini-cli/pull/17980) +- lower the default max retries to reduce contention by @sehoon38 in + [#17975](https://github.com/google-gemini/gemini-cli/pull/17975) +- fix(core): ensure YOLO mode auto-approves complex shell commands when parsing + fails by @abhipatel12 in + [#17920](https://github.com/google-gemini/gemini-cli/pull/17920) +- Fix broken link. by @g-samroberts in + [#17972](https://github.com/google-gemini/gemini-cli/pull/17972) +- Support ctrl-C and Ctrl-D correctly Refactor so InputPrompt has priority over + AppContainer for input handling. by @jacob314 in + [#17993](https://github.com/google-gemini/gemini-cli/pull/17993) +- Fix truncation for AskQuestion by @jacob314 in + [#18001](https://github.com/google-gemini/gemini-cli/pull/18001) +- fix(workflow): update maintainer check logic to be inclusive and + case-insensitive by @bdmorgan in + [#18009](https://github.com/google-gemini/gemini-cli/pull/18009) +- Fix Esc cancel during streaming by @LyalinDotCom in + [#18039](https://github.com/google-gemini/gemini-cli/pull/18039) +- feat(acp): add session resume support by @bdmorgan in + [#18043](https://github.com/google-gemini/gemini-cli/pull/18043) +- fix(ci): prevent stale PR closer from incorrectly closing new PRs by @bdmorgan + in [#18069](https://github.com/google-gemini/gemini-cli/pull/18069) +- chore: delete autoAccept setting unused in production by @victorvianna in + [#17862](https://github.com/google-gemini/gemini-cli/pull/17862) +- feat(plan): use placeholder for choice question "Other" option by @jerop in + [#18101](https://github.com/google-gemini/gemini-cli/pull/18101) +- docs: update clearContext to hookSpecificOutput by @jackwotherspoon in + [#18024](https://github.com/google-gemini/gemini-cli/pull/18024) +- docs-writer skill: Update docs writer skill by @jkcinouye in + [#17928](https://github.com/google-gemini/gemini-cli/pull/17928) +- Sehoon/oncall filter by @sehoon38 in + [#18105](https://github.com/google-gemini/gemini-cli/pull/18105) +- feat(core): add setting to disable loop detection by @SandyTao520 in + [#18008](https://github.com/google-gemini/gemini-cli/pull/18008) +- Docs: Revise docs/index.md by @jkcinouye in + [#17879](https://github.com/google-gemini/gemini-cli/pull/17879) +- Fix up/down arrow regression and add test. by @jacob314 in + [#18108](https://github.com/google-gemini/gemini-cli/pull/18108) +- fix(ui): prevent content leak in MaxSizedBox bottom overflow by @jerop in + [#17991](https://github.com/google-gemini/gemini-cli/pull/17991) +- refactor: migrate checks.ts utility to core and deduplicate by @jerop in + [#18139](https://github.com/google-gemini/gemini-cli/pull/18139) +- feat(core): implement tool name aliasing for backward compatibility by @SandyTao520 in - [#17342](https://github.com/google-gemini/gemini-cli/pull/17342) -- feat(mcp): add enable/disable commands for MCP servers (#11057) by @jasmeetsb - in [#16299](https://github.com/google-gemini/gemini-cli/pull/16299) -- fix(cli)!: Default to interactive mode for positional arguments by - @ishaanxgupta in - [#16329](https://github.com/google-gemini/gemini-cli/pull/16329) -- Fix issue #17080 by @jacob314 in - [#17100](https://github.com/google-gemini/gemini-cli/pull/17100) -- feat(core): Refresh agents after loading an extension. by @joshualitt in - [#17355](https://github.com/google-gemini/gemini-cli/pull/17355) -- fix(cli): include source in policy rule display by @allenhutchison in - [#17358](https://github.com/google-gemini/gemini-cli/pull/17358) -- fix: remove obsolete CloudCode PerDay quota and 120s terminal threshold by + [#17974](https://github.com/google-gemini/gemini-cli/pull/17974) +- docs: fix help-wanted label spelling by @pavan-sh in + [#18114](https://github.com/google-gemini/gemini-cli/pull/18114) +- feat(cli): implement automatic theme switching based on terminal background by + @Abhijit-2592 in + [#17976](https://github.com/google-gemini/gemini-cli/pull/17976) +- fix(ide): no-op refactoring that moves the connection logic to helper + functions by @skeshive in + [#18118](https://github.com/google-gemini/gemini-cli/pull/18118) +- feat: update review-frontend-and-fix slash command to review-and-fix by + @galz10 in [#18146](https://github.com/google-gemini/gemini-cli/pull/18146) +- fix: improve Ctrl+R reverse search by @jackwotherspoon in + [#18075](https://github.com/google-gemini/gemini-cli/pull/18075) +- feat(plan): handle inconsistency in schedulers by @Adib234 in + [#17813](https://github.com/google-gemini/gemini-cli/pull/17813) +- feat(plan): add core logic and exit_plan_mode tool definition by @jerop in + [#18110](https://github.com/google-gemini/gemini-cli/pull/18110) +- feat(core): rename search_file_content tool to grep_search and add legacy + alias by @SandyTao520 in + [#18003](https://github.com/google-gemini/gemini-cli/pull/18003) +- fix(core): prioritize detailed error messages for code assist setup by @gsquared94 in - [#17236](https://github.com/google-gemini/gemini-cli/pull/17236) -- Refactor subagent delegation to be one tool per agent by @gundermanc in - [#17346](https://github.com/google-gemini/gemini-cli/pull/17346) -- fix(core): Include MCP server name in OAuth message by @jerop in - [#17351](https://github.com/google-gemini/gemini-cli/pull/17351) -- Fix pr-triage.sh script to update pull requests with tags "help wanted" and - "maintainer only" by @jacob314 in - [#17324](https://github.com/google-gemini/gemini-cli/pull/17324) -- feat(plan): implement simple workflow for planning in main agent by @jerop in - [#17326](https://github.com/google-gemini/gemini-cli/pull/17326) -- fix: exit with non-zero code when esbuild is missing by @yuvrajangadsingh in - [#16967](https://github.com/google-gemini/gemini-cli/pull/16967) -- fix: ensure @docs/cli/custom-commands.md UI message ordering and test by - @medic-code in - [#12038](https://github.com/google-gemini/gemini-cli/pull/12038) -- fix(core): add alternative command names for Antigravity editor detec… by - @baeseokjae in - [#16829](https://github.com/google-gemini/gemini-cli/pull/16829) -- Refactor: Migrate CLI appEvents to Core coreEvents by @Adib234 in - [#15737](https://github.com/google-gemini/gemini-cli/pull/15737) -- fix(core): await MCP initialization in non-interactive mode by @Ratish1 in - [#17390](https://github.com/google-gemini/gemini-cli/pull/17390) -- Fix modifyOtherKeys enablement on unsupported terminals by @seekskyworld in - [#16714](https://github.com/google-gemini/gemini-cli/pull/16714) -- fix(core): gracefully handle disk full errors in chat recording by - @godwiniheuwa in - [#17305](https://github.com/google-gemini/gemini-cli/pull/17305) -- fix(oauth): update oauth to use 127.0.0.1 instead of localhost by @skeshive in - [#17388](https://github.com/google-gemini/gemini-cli/pull/17388) -- fix(core): use RFC 9728 compliant path-based OAuth protected resource - discovery by @vrv in - [#15756](https://github.com/google-gemini/gemini-cli/pull/15756) -- Update Code Wiki README badge by @PatoBeltran in - [#15229](https://github.com/google-gemini/gemini-cli/pull/15229) -- Add conda installation instructions for Gemini CLI by @ishaanxgupta in - [#16921](https://github.com/google-gemini/gemini-cli/pull/16921) -- chore(refactor): extract BaseSettingsDialog component by @SandyTao520 in - [#17369](https://github.com/google-gemini/gemini-cli/pull/17369) -- fix(cli): preserve input text when declining tool approval (#15624) by - @ManojINaik in - [#15659](https://github.com/google-gemini/gemini-cli/pull/15659) -- chore: upgrade dep: diff 7.0.0-> 8.0.3 by @scidomino in - [#17403](https://github.com/google-gemini/gemini-cli/pull/17403) -- feat: add AskUserDialog for UI component of AskUser tool by @jackwotherspoon - in [#17344](https://github.com/google-gemini/gemini-cli/pull/17344) -- feat(ui): display user tier in about command by @sehoon38 in - [#17400](https://github.com/google-gemini/gemini-cli/pull/17400) -- feat: add clearContext to AfterAgent hooks by @jackwotherspoon in - [#16574](https://github.com/google-gemini/gemini-cli/pull/16574) -- fix(cli): change image paste location to global temp directory (#17396) by - @devr0306 in [#17396](https://github.com/google-gemini/gemini-cli/pull/17396) -- Fix line endings issue with Notice file by @scidomino in - [#17417](https://github.com/google-gemini/gemini-cli/pull/17417) -- feat(plan): implement persistent approvalMode setting by @Adib234 in - [#17350](https://github.com/google-gemini/gemini-cli/pull/17350) -- feat(ui): Move keyboard handling into BaseSettingsDialog by @SandyTao520 in - [#17404](https://github.com/google-gemini/gemini-cli/pull/17404) -- Allow prompt queueing during MCP initialization by @Adib234 in - [#17395](https://github.com/google-gemini/gemini-cli/pull/17395) -- feat: implement AgentConfigDialog for /agents config command by @SandyTao520 - in [#17370](https://github.com/google-gemini/gemini-cli/pull/17370) -- fix(agents): default to all tools when tool list is omitted in subagents by - @gundermanc in - [#17422](https://github.com/google-gemini/gemini-cli/pull/17422) -- feat(cli): Moves tool confirmations to a queue UX by @abhipatel12 in - [#17276](https://github.com/google-gemini/gemini-cli/pull/17276) -- fix(core): hide user tier name by @sehoon38 in - [#17418](https://github.com/google-gemini/gemini-cli/pull/17418) -- feat: Enforce unified folder trust for /directory add by @galz10 in - [#17359](https://github.com/google-gemini/gemini-cli/pull/17359) -- migrate fireToolNotificationHook to hookSystem by @ved015 in - [#17398](https://github.com/google-gemini/gemini-cli/pull/17398) -- Clean up dead code by @scidomino in - [#17443](https://github.com/google-gemini/gemini-cli/pull/17443) -- feat(workflow): add stale pull request closer with linked-issue enforcement by - @bdmorgan in [#17449](https://github.com/google-gemini/gemini-cli/pull/17449) -- feat(workflow): expand stale-exempt labels to include help wanted and Public - Roadmap by @bdmorgan in - [#17459](https://github.com/google-gemini/gemini-cli/pull/17459) -- chore(workflow): remove redundant label-enforcer workflow by @bdmorgan in - [#17460](https://github.com/google-gemini/gemini-cli/pull/17460) -- Resolves the confusing error message `ripgrep exited with code null that - occurs when a search operation is cancelled or aborted by @maximmasiutin in - [#14267](https://github.com/google-gemini/gemini-cli/pull/14267) -- fix: detect pnpm/pnpx in ~/.local by @rwakulszowa in - [#15254](https://github.com/google-gemini/gemini-cli/pull/15254) -- docs: Add instructions for MacPorts and uninstall instructions for Homebrew by - @breun in [#17412](https://github.com/google-gemini/gemini-cli/pull/17412) -- docs(hooks): clarify mandatory 'type' field and update hook schema - documentation by @abhipatel12 in - [#17499](https://github.com/google-gemini/gemini-cli/pull/17499) -- Improve error messages on failed onboarding by @gsquared94 in - [#17357](https://github.com/google-gemini/gemini-cli/pull/17357) -- Follow up to "enableInteractiveShell for external tooling relying on a2a - server" by @DavidAPierce in - [#17130](https://github.com/google-gemini/gemini-cli/pull/17130) -- Fix/issue 17070 by @alih552 in - [#17242](https://github.com/google-gemini/gemini-cli/pull/17242) -- fix(core): handle URI-encoded workspace paths in IdeClient by @dong-jun-shin - in [#17476](https://github.com/google-gemini/gemini-cli/pull/17476) -- feat(cli): add quick clear input shortcuts in vim mode by @harshanadim in - [#17470](https://github.com/google-gemini/gemini-cli/pull/17470) -- feat(core): optimize shell tool llmContent output format by @SandyTao520 in - [#17538](https://github.com/google-gemini/gemini-cli/pull/17538) -- Fix bug in detecting already added paths. by @jacob314 in - [#17430](https://github.com/google-gemini/gemini-cli/pull/17430) -- feat(scheduler): support multi-scheduler tool aggregation and nested call IDs - by @abhipatel12 in - [#17429](https://github.com/google-gemini/gemini-cli/pull/17429) -- feat(agents): implement first-run experience for project-level sub-agents by - @gundermanc in - [#17266](https://github.com/google-gemini/gemini-cli/pull/17266) -- Update extensions docs by @chrstnb in - [#16093](https://github.com/google-gemini/gemini-cli/pull/16093) -- Docs: Refactor left nav on the website by @jkcinouye in - [#17558](https://github.com/google-gemini/gemini-cli/pull/17558) -- fix(core): stream grep/ripgrep output to prevent OOM by @adamfweidman in - [#17146](https://github.com/google-gemini/gemini-cli/pull/17146) -- feat(plan): add persistent plan file storage by @jerop in - [#17563](https://github.com/google-gemini/gemini-cli/pull/17563) -- feat(agents): migrate subagents to event-driven scheduler by @abhipatel12 in - [#17567](https://github.com/google-gemini/gemini-cli/pull/17567) -- Fix extensions config error by @chrstnb in - [#17580](https://github.com/google-gemini/gemini-cli/pull/17580) -- fix(plan): remove subagent invocation from plan mode by @jerop in - [#17593](https://github.com/google-gemini/gemini-cli/pull/17593) -- feat(ui): add solid background color option for input prompt by @jacob314 in - [#16563](https://github.com/google-gemini/gemini-cli/pull/16563) -- feat(plan): refresh system prompt when approval mode changes (Shift+Tab) by - @jerop in [#17585](https://github.com/google-gemini/gemini-cli/pull/17585) -- feat(cli): add global setting to disable UI spinners by @galz10 in - [#17234](https://github.com/google-gemini/gemini-cli/pull/17234) -- fix(security): enforce strict policy directory permissions by @yunaseoul in - [#17353](https://github.com/google-gemini/gemini-cli/pull/17353) -- test(core): fix tests in windows by @scidomino in - [#17592](https://github.com/google-gemini/gemini-cli/pull/17592) -- feat(mcp/extensions): Allow users to selectively enable/disable MCP servers - included in an extension( Issue #11057 & #17402) by @jasmeetsb in - [#17434](https://github.com/google-gemini/gemini-cli/pull/17434) -- Always map mac keys, even on other platforms by @scidomino in - [#17618](https://github.com/google-gemini/gemini-cli/pull/17618) -- Ctrl-O by @jacob314 in - [#17617](https://github.com/google-gemini/gemini-cli/pull/17617) -- feat(plan): update cycling order of approval modes by @Adib234 in - [#17622](https://github.com/google-gemini/gemini-cli/pull/17622) -- fix(cli): restore 'Modify with editor' option in external terminals by - @abhipatel12 in - [#17621](https://github.com/google-gemini/gemini-cli/pull/17621) -- Slash command for helping in debugging by @gundermanc in - [#17609](https://github.com/google-gemini/gemini-cli/pull/17609) -- feat: add double-click to expand/collapse large paste placeholders by - @jackwotherspoon in - [#17471](https://github.com/google-gemini/gemini-cli/pull/17471) -- refactor(cli): migrate non-interactive flow to event-driven scheduler by - @abhipatel12 in - [#17572](https://github.com/google-gemini/gemini-cli/pull/17572) -- fix: loadcodeassist eligible tiers getting ignored for unlicensed users - (regression) by @gsquared94 in - [#17581](https://github.com/google-gemini/gemini-cli/pull/17581) -- chore(core): delete legacy nonInteractiveToolExecutor by @abhipatel12 in - [#17573](https://github.com/google-gemini/gemini-cli/pull/17573) -- feat(core): enforce server prefixes for MCP tools in agent definitions by - @abhipatel12 in - [#17574](https://github.com/google-gemini/gemini-cli/pull/17574) -- feat (mcp): Refresh MCP prompts on list changed notification by @MrLesk in - [#14863](https://github.com/google-gemini/gemini-cli/pull/14863) -- feat(ui): pretty JSON rendering tool outputs by @medic-code in - [#9767](https://github.com/google-gemini/gemini-cli/pull/9767) -- Fix iterm alternate buffer mode issue rendering backgrounds by @jacob314 in - [#17634](https://github.com/google-gemini/gemini-cli/pull/17634) -- feat(cli): add gemini extensions list --output-format=json by @AkihiroSuda in - [#14479](https://github.com/google-gemini/gemini-cli/pull/14479) -- fix(extensions): add .gitignore to extension templates by @godwiniheuwa in - [#17293](https://github.com/google-gemini/gemini-cli/pull/17293) -- paste transform followup by @jacob314 in - [#17624](https://github.com/google-gemini/gemini-cli/pull/17624) -- refactor: rename formatMemoryUsage to formatBytes by @Nubebuster in - [#14997](https://github.com/google-gemini/gemini-cli/pull/14997) -- chore: remove extra top margin from /hooks and /extensions by @jackwotherspoon - in [#17663](https://github.com/google-gemini/gemini-cli/pull/17663) -- feat(cli): add oncall command for issue triage by @sehoon38 in - [#17661](https://github.com/google-gemini/gemini-cli/pull/17661) -- Fix sidebar issue for extensions link by @chrstnb in - [#17668](https://github.com/google-gemini/gemini-cli/pull/17668) -- Change formatting to prevent UI redressing attacks by @scidomino in - [#17611](https://github.com/google-gemini/gemini-cli/pull/17611) -- Fix cluster of bugs in the settings dialog. by @jacob314 in - [#17628](https://github.com/google-gemini/gemini-cli/pull/17628) -- Update sidebar to resolve site build issues by @chrstnb in - [#17674](https://github.com/google-gemini/gemini-cli/pull/17674) -- fix(admin): fix a few bugs related to admin controls by @skeshive in - [#17590](https://github.com/google-gemini/gemini-cli/pull/17590) -- revert bad changes to tests by @scidomino in - [#17673](https://github.com/google-gemini/gemini-cli/pull/17673) -- feat(cli): show candidate issue state reason and duplicate status in triage by - @sehoon38 in [#17676](https://github.com/google-gemini/gemini-cli/pull/17676) -- Fix missing slash commands when Gemini CLI is in a project with a package.json - that doesn't follow semantic versioning by @Adib234 in - [#17561](https://github.com/google-gemini/gemini-cli/pull/17561) -- feat(core): Model family-specific system prompts by @joshualitt in - [#17614](https://github.com/google-gemini/gemini-cli/pull/17614) -- Sub-agents documentation. by @gundermanc in - [#16639](https://github.com/google-gemini/gemini-cli/pull/16639) -- feat: wire up AskUserTool with dialog by @jackwotherspoon in - [#17411](https://github.com/google-gemini/gemini-cli/pull/17411) -- Load extension settings for hooks, agents, skills by @chrstnb in - [#17245](https://github.com/google-gemini/gemini-cli/pull/17245) -- Fix issue where Gemini CLI can make changes when simply asked a question by - @gundermanc in - [#17608](https://github.com/google-gemini/gemini-cli/pull/17608) -- Update docs-writer skill for editing and add style guide for reference. by - @g-samroberts in - [#17669](https://github.com/google-gemini/gemini-cli/pull/17669) -- fix(ux): have user message display a short path for pasted images by @devr0306 - in [#17613](https://github.com/google-gemini/gemini-cli/pull/17613) -- feat(plan): enable AskUser tool in Plan mode for clarifying questions by - @jerop in [#17694](https://github.com/google-gemini/gemini-cli/pull/17694) -- GEMINI.md polish by @jacob314 in - [#17680](https://github.com/google-gemini/gemini-cli/pull/17680) -- refactor(core): centralize path validation and allow temp dir access for tools - by @NTaylorMullen in - [#17185](https://github.com/google-gemini/gemini-cli/pull/17185) -- feat(skills): promote Agent Skills to stable by @abhipatel12 in - [#17693](https://github.com/google-gemini/gemini-cli/pull/17693) -- refactor(cli): keyboard handling and AskUserDialog by @jacob314 in - [#17414](https://github.com/google-gemini/gemini-cli/pull/17414) -- docs: Add Experimental Remote Agent Docs by @adamfweidman in - [#17697](https://github.com/google-gemini/gemini-cli/pull/17697) -- revert: promote Agent Skills to stable (#17693) by @abhipatel12 in - [#17712](https://github.com/google-gemini/gemini-cli/pull/17712) -- feat(ux) Expandable (ctrl-O) and scrollable approvals in alternate buffer - mode. by @jacob314 in - [#17640](https://github.com/google-gemini/gemini-cli/pull/17640) -- feat(skills): promote skills settings to stable by @abhipatel12 in - [#17713](https://github.com/google-gemini/gemini-cli/pull/17713) -- fix(cli): Preserve settings dialog focus when searching by @SandyTao520 in - [#17701](https://github.com/google-gemini/gemini-cli/pull/17701) -- feat(ui): add terminal cursor support by @jacob314 in - [#17711](https://github.com/google-gemini/gemini-cli/pull/17711) -- docs(skills): remove experimental labels and update tutorials by @abhipatel12 - in [#17714](https://github.com/google-gemini/gemini-cli/pull/17714) -- docs: remove 'experimental' syntax for hooks in docs by @abhipatel12 in - [#17660](https://github.com/google-gemini/gemini-cli/pull/17660) -- Add support for an additional exclusion file besides .gitignore and - .geminiignore by @alisa-alisa in - [#16487](https://github.com/google-gemini/gemini-cli/pull/16487) -- feat: add review-frontend-and-fix command by @galz10 in - [#17707](https://github.com/google-gemini/gemini-cli/pull/17707) + [#17852](https://github.com/google-gemini/gemini-cli/pull/17852) +- fix(cli): resolve environment loading and auth validation issues in ACP mode + by @bdmorgan in + [#18025](https://github.com/google-gemini/gemini-cli/pull/18025) +- feat(core): add .agents/skills directory alias for skill discovery by + @NTaylorMullen in + [#18151](https://github.com/google-gemini/gemini-cli/pull/18151) +- chore(core): reassign telemetry keys to avoid server conflict by @mattKorwel + in [#18161](https://github.com/google-gemini/gemini-cli/pull/18161) +- Add link to rewind doc in commands.md by @Adib234 in + [#17961](https://github.com/google-gemini/gemini-cli/pull/17961) +- feat(core): add draft-2020-12 JSON Schema support with lenient fallback by + @afarber in [#15060](https://github.com/google-gemini/gemini-cli/pull/15060) +- refactor(core): robust trimPreservingTrailingNewline and regression test by + @adamfweidman in + [#18196](https://github.com/google-gemini/gemini-cli/pull/18196) +- Remove MCP servers on extension uninstall by @chrstnb in + [#18121](https://github.com/google-gemini/gemini-cli/pull/18121) +- refactor: localize ACP error parsing logic to cli package by @bdmorgan in + [#18193](https://github.com/google-gemini/gemini-cli/pull/18193) +- feat(core): Add A2A auth config types by @adamfweidman in + [#18205](https://github.com/google-gemini/gemini-cli/pull/18205) +- Set default max attempts to 3 and use the common variable by @sehoon38 in + [#18209](https://github.com/google-gemini/gemini-cli/pull/18209) +- feat(plan): add exit_plan_mode ui and prompt by @jerop in + [#18162](https://github.com/google-gemini/gemini-cli/pull/18162) +- fix(test): improve test isolation and enable subagent evaluations by + @cocosheng-g in + [#18138](https://github.com/google-gemini/gemini-cli/pull/18138) +- feat(plan): use custom deny messages in plan mode policies by @Adib234 in + [#18195](https://github.com/google-gemini/gemini-cli/pull/18195) +- Match on extension ID when stopping extensions by @chrstnb in + [#18218](https://github.com/google-gemini/gemini-cli/pull/18218) +- fix(core): Respect user's .gitignore preference by @xyrolle in + [#15482](https://github.com/google-gemini/gemini-cli/pull/15482) +- docs: document GEMINI_CLI_HOME environment variable by @adamfweidman in + [#18219](https://github.com/google-gemini/gemini-cli/pull/18219) +- chore(core): explicitly state plan storage path in prompt by @jerop in + [#18222](https://github.com/google-gemini/gemini-cli/pull/18222) +- A2a admin setting by @DavidAPierce in + [#17868](https://github.com/google-gemini/gemini-cli/pull/17868) +- feat(a2a): Add pluggable auth provider infrastructure by @adamfweidman in + [#17934](https://github.com/google-gemini/gemini-cli/pull/17934) +- Fix handling of empty settings by @chrstnb in + [#18131](https://github.com/google-gemini/gemini-cli/pull/18131) +- Reload skills when extensions change by @chrstnb in + [#18225](https://github.com/google-gemini/gemini-cli/pull/18225) +- feat: Add markdown rendering to ask_user tool by @jackwotherspoon in + [#18211](https://github.com/google-gemini/gemini-cli/pull/18211) +- Add telemetry to rewind by @Adib234 in + [#18122](https://github.com/google-gemini/gemini-cli/pull/18122) +- feat(admin): add support for MCP configuration via admin controls (pt1) by + @skeshive in [#18223](https://github.com/google-gemini/gemini-cli/pull/18223) +- feat(core): require user consent before MCP server OAuth by @ehedlund in + [#18132](https://github.com/google-gemini/gemini-cli/pull/18132) +- fix(sandbox): propagate GOOGLE_GEMINI_BASE_URL&GOOGLE_VERTEX_BASE_URL env vars + by @skeshive in + [#18231](https://github.com/google-gemini/gemini-cli/pull/18231) +- feat(ui): move user identity display to header by @sehoon38 in + [#18216](https://github.com/google-gemini/gemini-cli/pull/18216) +- fix: enforce folder trust for workspace settings, skills, and context by + @galz10 in [#17596](https://github.com/google-gemini/gemini-cli/pull/17596) -**Full changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.26.0...v0.27.0 +**Full Changelog**: +https://github.com/google-gemini/gemini-cli/compare/v0.27.0...v0.28.0 diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index 93ed5a2a9cb..cab75c44463 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: Release v0.28.0-preview.0 +# Preview release: Release v0.29.0-preview.0 -Released: February 3, 2026 +Released: February 10, 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,295 +13,355 @@ npm install -g @google/gemini-cli@preview ## Highlights -- **Improved Hooks Management:** Hooks enable/disable functionality now aligns - with skills and offers improved completion. -- **Custom Themes for Extensions:** Extensions can now support custom themes, - allowing for greater personalization. -- **User Identity Display:** User identity information (auth, email, tier) is - now displayed on startup and in the `stats` command. -- **Plan Mode Enhancements:** Plan mode has been improved with a generic - `Checklist` component and refactored `Todo`. -- **Background Shell Commands:** Implementation of background shell commands. +- **Plan Mode Enhancements**: Significant updates to Plan Mode, including new + commands, support for MCP servers, integration of planning artifacts, and + improved iteration guidance. +- **Core Agent Improvements**: Enhancements to the core agent, including better + system prompt rigor, improved subagent definitions, and enhanced tool + execution limits. +- **CLI UX/UI Updates**: Various UI and UX improvements, such as autocomplete in + the input prompt, updated approval mode labels, DevTools integration, and + improved header spacing. +- **Tooling & Extension Updates**: Improvements to existing tools like + `ask_user` and `grep_search`, and new features for extension management. +- **Bug Fixes**: Numerous bug fixes across the CLI and core, addressing issues + with interactive commands, memory leaks, permission checks, and more. +- **Context and Tool Output Management**: Features for observation masking for + tool outputs, session-linked tool output storage, and persistence for masked + tool outputs. ## What's Changed -- feat(commands): add /prompt-suggest slash command by NTaylorMullen in - [#17264](https://github.com/google-gemini/gemini-cli/pull/17264) -- feat(cli): align hooks enable/disable with skills and improve completion by - sehoon38 in [#16822](https://github.com/google-gemini/gemini-cli/pull/16822) -- docs: add CLI reference documentation by leochiu-a in - [#17504](https://github.com/google-gemini/gemini-cli/pull/17504) -- chore(release): bump version to 0.28.0-nightly.20260128.adc8e11bb by +- fix: remove ask_user tool from non-interactive modes by jackwotherspoon in + [#18154](https://github.com/google-gemini/gemini-cli/pull/18154) +- fix(cli): allow restricted .env loading in untrusted sandboxed folders by + galz10 in [#17806](https://github.com/google-gemini/gemini-cli/pull/17806) +- Encourage agent to utilize ecosystem tools to perform work by gundermanc in + [#17881](https://github.com/google-gemini/gemini-cli/pull/17881) +- feat(plan): unify workflow location in system prompt to optimize caching by + jerop in [#18258](https://github.com/google-gemini/gemini-cli/pull/18258) +- feat(core): enable getUserTierName in config by sehoon38 in + [#18265](https://github.com/google-gemini/gemini-cli/pull/18265) +- feat(core): add default execution limits for subagents by abhipatel12 in + [#18274](https://github.com/google-gemini/gemini-cli/pull/18274) +- Fix issue where agent gets stuck at interactive commands. by gundermanc in + [#18272](https://github.com/google-gemini/gemini-cli/pull/18272) +- chore(release): bump version to 0.29.0-nightly.20260203.71f46f116 by gemini-cli-robot in - [#17725](https://github.com/google-gemini/gemini-cli/pull/17725) -- feat(skills): final stable promotion cleanup by abhipatel12 in - [#17726](https://github.com/google-gemini/gemini-cli/pull/17726) -- test(core): mock fetch in OAuth transport fallback tests by jw409 in - [#17059](https://github.com/google-gemini/gemini-cli/pull/17059) -- feat(cli): include auth method in /bug by erikus in - [#17569](https://github.com/google-gemini/gemini-cli/pull/17569) -- Add a email privacy note to bug_report template by nemyung in - [#17474](https://github.com/google-gemini/gemini-cli/pull/17474) -- Rewind documentation by Adib234 in - [#17446](https://github.com/google-gemini/gemini-cli/pull/17446) -- fix: verify audio/video MIME types with content check by maru0804 in - [#16907](https://github.com/google-gemini/gemini-cli/pull/16907) -- feat(core): add support for positron ide (#15045) by kapsner in - [#15047](https://github.com/google-gemini/gemini-cli/pull/15047) -- /oncall dedup - wrap texts to nextlines by sehoon38 in - [#17782](https://github.com/google-gemini/gemini-cli/pull/17782) -- fix(admin): rename advanced features admin setting by skeshive in - [#17786](https://github.com/google-gemini/gemini-cli/pull/17786) -- [extension config] Make breaking optional value non-optional by chrstnb in - [#17785](https://github.com/google-gemini/gemini-cli/pull/17785) -- Fix docs-writer skill issues by g-samroberts in - [#17734](https://github.com/google-gemini/gemini-cli/pull/17734) -- fix(core): suppress duplicate hook failure warnings during streaming by + [#18243](https://github.com/google-gemini/gemini-cli/pull/18243) +- feat(core): remove hardcoded policy bypass for local subagents by abhipatel12 + in [#18153](https://github.com/google-gemini/gemini-cli/pull/18153) +- feat(plan): implement plan slash command by Adib234 in + [#17698](https://github.com/google-gemini/gemini-cli/pull/17698) +- feat: increase ask_user label limit to 16 characters by jackwotherspoon in + [#18320](https://github.com/google-gemini/gemini-cli/pull/18320) +- Add information about the agent skills lifecycle and clarify docs-writer skill + metadata. by g-samroberts in + [#18234](https://github.com/google-gemini/gemini-cli/pull/18234) +- feat(core): add enter_plan_mode tool by jerop in + [#18324](https://github.com/google-gemini/gemini-cli/pull/18324) +- Stop showing an error message in /plan by Adib234 in + [#18333](https://github.com/google-gemini/gemini-cli/pull/18333) +- fix(hooks): remove unnecessary logging for hook registration by abhipatel12 in + [#18332](https://github.com/google-gemini/gemini-cli/pull/18332) +- fix(mcp): ensure MCP transport is closed to prevent memory leaks by cbcoutinho + in [#18054](https://github.com/google-gemini/gemini-cli/pull/18054) +- feat(skills): implement linking for agent skills by MushuEE in + [#18295](https://github.com/google-gemini/gemini-cli/pull/18295) +- Changelogs for 0.27.0 and 0.28.0-preview0 by g-samroberts in + [#18336](https://github.com/google-gemini/gemini-cli/pull/18336) +- chore: correct docs as skills and hooks are stable by jackwotherspoon in + [#18358](https://github.com/google-gemini/gemini-cli/pull/18358) +- feat(admin): Implement admin allowlist for MCP server configurations by + skeshive in [#18311](https://github.com/google-gemini/gemini-cli/pull/18311) +- fix(core): add retry logic for transient SSL/TLS errors + ([#17318](https://github.com/google-gemini/gemini-cli/pull/17318)) by + ppgranger in [#18310](https://github.com/google-gemini/gemini-cli/pull/18310) +- Add support for /extensions config command by chrstnb in + [#17895](https://github.com/google-gemini/gemini-cli/pull/17895) +- fix(core): handle non-compliant mcpbridge responses from Xcode 26.3 by + peterfriese in + [#18376](https://github.com/google-gemini/gemini-cli/pull/18376) +- feat(cli): Add W, B, E Vim motions and operator support by ademuri in + [#16209](https://github.com/google-gemini/gemini-cli/pull/16209) +- fix: Windows Specific Agent Quality & System Prompt by scidomino in + [#18351](https://github.com/google-gemini/gemini-cli/pull/18351) +- feat(plan): support replace tool in plan mode to edit plans by jerop in + [#18379](https://github.com/google-gemini/gemini-cli/pull/18379) +- Improving memory tool instructions and eval testing by alisa-alisa in + [#18091](https://github.com/google-gemini/gemini-cli/pull/18091) +- fix(cli): color extension link success message green by MushuEE in + [#18386](https://github.com/google-gemini/gemini-cli/pull/18386) +- undo by jacob314 in + [#18147](https://github.com/google-gemini/gemini-cli/pull/18147) +- feat(plan): add guidance on iterating on approved plans vs creating new plans + by jerop in [#18346](https://github.com/google-gemini/gemini-cli/pull/18346) +- feat(plan): fix invalid tool calls in plan mode by Adib234 in + [#18352](https://github.com/google-gemini/gemini-cli/pull/18352) +- feat(plan): integrate planning artifacts and tools into primary workflows by + jerop in [#18375](https://github.com/google-gemini/gemini-cli/pull/18375) +- Fix permission check by scidomino in + [#18395](https://github.com/google-gemini/gemini-cli/pull/18395) +- ux(polish) autocomplete in the input prompt by jacob314 in + [#18181](https://github.com/google-gemini/gemini-cli/pull/18181) +- fix: resolve infinite loop when using 'Modify with external editor' by + ppgranger in [#17453](https://github.com/google-gemini/gemini-cli/pull/17453) +- feat: expand verify-release to macOS and Windows by yunaseoul in + [#18145](https://github.com/google-gemini/gemini-cli/pull/18145) +- feat(plan): implement support for MCP servers in Plan mode by Adib234 in + [#18229](https://github.com/google-gemini/gemini-cli/pull/18229) +- chore: update folder trust error messaging by galz10 in + [#18402](https://github.com/google-gemini/gemini-cli/pull/18402) +- feat(plan): create a metric for execution of plans generated in plan mode by + Adib234 in [#18236](https://github.com/google-gemini/gemini-cli/pull/18236) +- perf(ui): optimize stripUnsafeCharacters with regex by gsquared94 in + [#18413](https://github.com/google-gemini/gemini-cli/pull/18413) +- feat(context): implement observation masking for tool outputs by abhipatel12 + in [#18389](https://github.com/google-gemini/gemini-cli/pull/18389) +- feat(core,cli): implement session-linked tool output storage and cleanup by abhipatel12 in - [#17727](https://github.com/google-gemini/gemini-cli/pull/17727) -- test: add more tests for AskUser by jackwotherspoon in - [#17720](https://github.com/google-gemini/gemini-cli/pull/17720) -- feat(cli): enable activity logging for non-interactive mode and evals by + [#18416](https://github.com/google-gemini/gemini-cli/pull/18416) +- Shorten temp directory by joshualitt in + [#17901](https://github.com/google-gemini/gemini-cli/pull/17901) +- feat(plan): add behavioral evals for plan mode by jerop in + [#18437](https://github.com/google-gemini/gemini-cli/pull/18437) +- Add extension registry client by chrstnb in + [#18396](https://github.com/google-gemini/gemini-cli/pull/18396) +- Enable extension config by default by chrstnb in + [#18447](https://github.com/google-gemini/gemini-cli/pull/18447) +- Automatically generate change logs on release by g-samroberts in + [#18401](https://github.com/google-gemini/gemini-cli/pull/18401) +- Remove previewFeatures and default to Gemini 3 by sehoon38 in + [#18414](https://github.com/google-gemini/gemini-cli/pull/18414) +- feat(admin): apply MCP allowlist to extensions & gemini mcp list command by + skeshive in [#18442](https://github.com/google-gemini/gemini-cli/pull/18442) +- fix(cli): improve focus navigation for interactive and background shells by + galz10 in [#18343](https://github.com/google-gemini/gemini-cli/pull/18343) +- Add shortcuts hint and panel for discoverability by LyalinDotCom in + [#18035](https://github.com/google-gemini/gemini-cli/pull/18035) +- fix(config): treat system settings as read-only during migration and warn user + by spencer426 in + [#18277](https://github.com/google-gemini/gemini-cli/pull/18277) +- feat(plan): add positive test case and update eval stability policy by jerop + in [#18457](https://github.com/google-gemini/gemini-cli/pull/18457) +- fix- windows: add shell: true for spawnSync to fix EINVAL with .cmd editors by + zackoch in [#18408](https://github.com/google-gemini/gemini-cli/pull/18408) +- bug(core): Fix bug when saving plans. by joshualitt in + [#18465](https://github.com/google-gemini/gemini-cli/pull/18465) +- Refactor atCommandProcessor by scidomino in + [#18461](https://github.com/google-gemini/gemini-cli/pull/18461) +- feat(core): implement persistence and resumption for masked tool outputs by + abhipatel12 in + [#18451](https://github.com/google-gemini/gemini-cli/pull/18451) +- refactor: simplify tool output truncation to single config by SandyTao520 in + [#18446](https://github.com/google-gemini/gemini-cli/pull/18446) +- bug(core): Ensure storage is initialized early, even if config is not. by + joshualitt in [#18471](https://github.com/google-gemini/gemini-cli/pull/18471) +- chore: Update build-and-start script to support argument forwarding by + Abhijit-2592 in + [#18241](https://github.com/google-gemini/gemini-cli/pull/18241) +- fix(core): prevent subagent bypass in plan mode by jerop in + [#18484](https://github.com/google-gemini/gemini-cli/pull/18484) +- feat(cli): add WebSocket-based network logging and streaming chunk support by SandyTao520 in - [#17703](https://github.com/google-gemini/gemini-cli/pull/17703) -- feat(core): add support for custom deny messages in policy rules by - allenhutchison in - [#17427](https://github.com/google-gemini/gemini-cli/pull/17427) -- Fix unintended credential exposure to MCP Servers by Adib234 in - [#17311](https://github.com/google-gemini/gemini-cli/pull/17311) -- feat(extensions): add support for custom themes in extensions by spencer426 in - [#17327](https://github.com/google-gemini/gemini-cli/pull/17327) -- fix: persist and restore workspace directories on session resume by - korade-krushna in - [#17454](https://github.com/google-gemini/gemini-cli/pull/17454) -- Update release notes pages for 0.26.0 and 0.27.0-preview. by g-samroberts in - [#17744](https://github.com/google-gemini/gemini-cli/pull/17744) -- feat(ux): update cell border color and created test file for table rendering - by devr0306 in - [#17798](https://github.com/google-gemini/gemini-cli/pull/17798) -- Change height for the ToolConfirmationQueue. by jacob314 in - [#17799](https://github.com/google-gemini/gemini-cli/pull/17799) -- feat(cli): add user identity info to stats command by sehoon38 in - [#17612](https://github.com/google-gemini/gemini-cli/pull/17612) -- fix(ux): fixed off-by-some wrapping caused by fixed-width characters by - devr0306 in [#17816](https://github.com/google-gemini/gemini-cli/pull/17816) -- feat(cli): update undo/redo keybindings to Cmd+Z/Alt+Z and - Shift+Cmd+Z/Shift+Alt+Z by scidomino in - [#17800](https://github.com/google-gemini/gemini-cli/pull/17800) -- fix(evals): use absolute path for activity log directory by SandyTao520 in - [#17830](https://github.com/google-gemini/gemini-cli/pull/17830) -- test: add integration test to verify stdout/stderr routing by ved015 in - [#17280](https://github.com/google-gemini/gemini-cli/pull/17280) -- fix(cli): list installed extensions when update target missing by tt-a1i in - [#17082](https://github.com/google-gemini/gemini-cli/pull/17082) -- fix(cli): handle PAT tokens and credentials in git remote URL parsing by - afarber in [#14650](https://github.com/google-gemini/gemini-cli/pull/14650) -- fix(core): use returnDisplay for error result display by Nubebuster in - [#14994](https://github.com/google-gemini/gemini-cli/pull/14994) -- Fix detection of bun as package manager by Randomblock1 in - [#17462](https://github.com/google-gemini/gemini-cli/pull/17462) -- feat(cli): show hooksConfig.enabled in settings dialog by abhipatel12 in - [#17810](https://github.com/google-gemini/gemini-cli/pull/17810) -- feat(cli): Display user identity (auth, email, tier) on startup by yunaseoul - in [#17591](https://github.com/google-gemini/gemini-cli/pull/17591) -- fix: prevent ghost border for AskUserDialog by jackwotherspoon in - [#17788](https://github.com/google-gemini/gemini-cli/pull/17788) -- docs: mark A2A subagents as experimental in subagents.md by adamfweidman in - [#17863](https://github.com/google-gemini/gemini-cli/pull/17863) -- Resolve error thrown for sensitive values by chrstnb in - [#17826](https://github.com/google-gemini/gemini-cli/pull/17826) -- fix(admin): Rename secureModeEnabled to strictModeDisabled by skeshive in - [#17789](https://github.com/google-gemini/gemini-cli/pull/17789) -- feat(ux): update truncate dots to be shorter in tables by devr0306 in - [#17825](https://github.com/google-gemini/gemini-cli/pull/17825) -- fix(core): resolve DEP0040 punycode deprecation via patch-package by - ATHARVA262005 in - [#17692](https://github.com/google-gemini/gemini-cli/pull/17692) -- feat(plan): create generic Checklist component and refactor Todo by Adib234 in - [#17741](https://github.com/google-gemini/gemini-cli/pull/17741) -- Cleanup post delegate_to_agent removal by gundermanc in - [#17875](https://github.com/google-gemini/gemini-cli/pull/17875) -- fix(core): use GIT_CONFIG_GLOBAL to isolate shadow git repo configuration - - Fixes #17877 by cocosheng-g in - [#17803](https://github.com/google-gemini/gemini-cli/pull/17803) -- Disable mouse tracking e2e by alisa-alisa in - [#17880](https://github.com/google-gemini/gemini-cli/pull/17880) -- fix(cli): use correct setting key for Cloud Shell auth by sehoon38 in - [#17884](https://github.com/google-gemini/gemini-cli/pull/17884) -- chore: revert IDE specific ASCII logo by jackwotherspoon in - [#17887](https://github.com/google-gemini/gemini-cli/pull/17887) -- Revert "fix(core): resolve DEP0040 punycode deprecation via patch-package" by - sehoon38 in [#17898](https://github.com/google-gemini/gemini-cli/pull/17898) -- Refactoring of disabling of mouse tracking in e2e tests by alisa-alisa in - [#17902](https://github.com/google-gemini/gemini-cli/pull/17902) -- feat(core): Add GOOGLE_GENAI_API_VERSION environment variable support by deyim - in [#16177](https://github.com/google-gemini/gemini-cli/pull/16177) -- feat(core): Isolate and cleanup truncated tool outputs by SandyTao520 in - [#17594](https://github.com/google-gemini/gemini-cli/pull/17594) -- Create skills page, update commands, refine docs by g-samroberts in - [#17842](https://github.com/google-gemini/gemini-cli/pull/17842) -- feat: preserve EOL in files by Thomas-Shephard in - [#16087](https://github.com/google-gemini/gemini-cli/pull/16087) -- Fix HalfLinePaddedBox in screenreader mode. by jacob314 in - [#17914](https://github.com/google-gemini/gemini-cli/pull/17914) -- bug(ux) vim mode fixes. Start in insert mode. Fix bug blocking F12 and ctrl-X - in vim mode. by jacob314 in - [#17938](https://github.com/google-gemini/gemini-cli/pull/17938) -- feat(core): implement interactive and non-interactive consent for OAuth by - ehedlund in [#17699](https://github.com/google-gemini/gemini-cli/pull/17699) -- perf(core): optimize token calculation and add support for multimodal tool - responses by abhipatel12 in - [#17835](https://github.com/google-gemini/gemini-cli/pull/17835) -- refactor(hooks): remove legacy tools.enableHooks setting by abhipatel12 in - [#17867](https://github.com/google-gemini/gemini-cli/pull/17867) -- feat(ci): add npx smoke test to verify installability by bdmorgan in - [#17927](https://github.com/google-gemini/gemini-cli/pull/17927) -- feat(core): implement dynamic policy registration for subagents by abhipatel12 - in [#17838](https://github.com/google-gemini/gemini-cli/pull/17838) -- feat: Implement background shell commands by galz10 in - [#14849](https://github.com/google-gemini/gemini-cli/pull/14849) -- feat(admin): provide actionable error messages for disabled features by - skeshive in [#17815](https://github.com/google-gemini/gemini-cli/pull/17815) -- Fix bugs where Rewind and Resume showed Ugly and 100X too verbose content. by - jacob314 in [#17940](https://github.com/google-gemini/gemini-cli/pull/17940) -- Fix broken link in docs by chrstnb in - [#17959](https://github.com/google-gemini/gemini-cli/pull/17959) -- feat(plan): reuse standard tool confirmation for AskUser tool by jerop in - [#17864](https://github.com/google-gemini/gemini-cli/pull/17864) -- feat(core): enable overriding CODE_ASSIST_API_VERSION with env var by - lottielin in [#17942](https://github.com/google-gemini/gemini-cli/pull/17942) -- run npx pointing to the specific commit SHA by sehoon38 in - [#17970](https://github.com/google-gemini/gemini-cli/pull/17970) -- Add allowedExtensions setting by kevinjwang1 in - [#17695](https://github.com/google-gemini/gemini-cli/pull/17695) -- feat(plan): refactor ToolConfirmationPayload to union type by jerop in - [#17980](https://github.com/google-gemini/gemini-cli/pull/17980) -- lower the default max retries to reduce contention by sehoon38 in - [#17975](https://github.com/google-gemini/gemini-cli/pull/17975) -- fix(core): ensure YOLO mode auto-approves complex shell commands when parsing - fails by abhipatel12 in - [#17920](https://github.com/google-gemini/gemini-cli/pull/17920) -- Fix broken link. by g-samroberts in - [#17972](https://github.com/google-gemini/gemini-cli/pull/17972) -- Support ctrl-C and Ctrl-D correctly Refactor so InputPrompt has priority over - AppContainer for input handling. by jacob314 in - [#17993](https://github.com/google-gemini/gemini-cli/pull/17993) -- Fix truncation for AskQuestion by jacob314 in - [#18001](https://github.com/google-gemini/gemini-cli/pull/18001) -- fix(workflow): update maintainer check logic to be inclusive and - case-insensitive by bdmorgan in - [#18009](https://github.com/google-gemini/gemini-cli/pull/18009) -- Fix Esc cancel during streaming by LyalinDotCom in - [#18039](https://github.com/google-gemini/gemini-cli/pull/18039) -- feat(acp): add session resume support by bdmorgan in - [#18043](https://github.com/google-gemini/gemini-cli/pull/18043) -- fix(ci): prevent stale PR closer from incorrectly closing new PRs by bdmorgan - in [#18069](https://github.com/google-gemini/gemini-cli/pull/18069) -- chore: delete autoAccept setting unused in production by victorvianna in - [#17862](https://github.com/google-gemini/gemini-cli/pull/17862) -- feat(plan): use placeholder for choice question "Other" option by jerop in - [#18101](https://github.com/google-gemini/gemini-cli/pull/18101) -- docs: update clearContext to hookSpecificOutput by jackwotherspoon in - [#18024](https://github.com/google-gemini/gemini-cli/pull/18024) -- docs-writer skill: Update docs writer skill by jkcinouye in - [#17928](https://github.com/google-gemini/gemini-cli/pull/17928) -- Sehoon/oncall filter by sehoon38 in - [#18105](https://github.com/google-gemini/gemini-cli/pull/18105) -- feat(core): add setting to disable loop detection by SandyTao520 in - [#18008](https://github.com/google-gemini/gemini-cli/pull/18008) -- Docs: Revise docs/index.md by jkcinouye in - [#17879](https://github.com/google-gemini/gemini-cli/pull/17879) -- Fix up/down arrow regression and add test. by jacob314 in - [#18108](https://github.com/google-gemini/gemini-cli/pull/18108) -- fix(ui): prevent content leak in MaxSizedBox bottom overflow by jerop in - [#17991](https://github.com/google-gemini/gemini-cli/pull/17991) -- refactor: migrate checks.ts utility to core and deduplicate by jerop in - [#18139](https://github.com/google-gemini/gemini-cli/pull/18139) -- feat(core): implement tool name aliasing for backward compatibility by + [#18383](https://github.com/google-gemini/gemini-cli/pull/18383) +- feat(cli): update approval modes UI by jerop in + [#18476](https://github.com/google-gemini/gemini-cli/pull/18476) +- fix(cli): reload skills and agents on extension restart by NTaylorMullen in + [#18411](https://github.com/google-gemini/gemini-cli/pull/18411) +- fix(core): expand excludeTools with legacy aliases for renamed tools by SandyTao520 in - [#17974](https://github.com/google-gemini/gemini-cli/pull/17974) -- docs: fix help-wanted label spelling by pavan-sh in - [#18114](https://github.com/google-gemini/gemini-cli/pull/18114) -- feat(cli): implement automatic theme switching based on terminal background by - Abhijit-2592 in - [#17976](https://github.com/google-gemini/gemini-cli/pull/17976) -- fix(ide): no-op refactoring that moves the connection logic to helper - functions by skeshive in - [#18118](https://github.com/google-gemini/gemini-cli/pull/18118) -- feat: update review-frontend-and-fix slash command to review-and-fix by galz10 - in [#18146](https://github.com/google-gemini/gemini-cli/pull/18146) -- fix: improve Ctrl+R reverse search by jackwotherspoon in - [#18075](https://github.com/google-gemini/gemini-cli/pull/18075) -- feat(plan): handle inconsistency in schedulers by Adib234 in - [#17813](https://github.com/google-gemini/gemini-cli/pull/17813) -- feat(plan): add core logic and exit_plan_mode tool definition by jerop in - [#18110](https://github.com/google-gemini/gemini-cli/pull/18110) -- feat(core): rename search_file_content tool to grep_search and add legacy - alias by SandyTao520 in - [#18003](https://github.com/google-gemini/gemini-cli/pull/18003) -- fix(core): prioritize detailed error messages for code assist setup by - gsquared94 in [#17852](https://github.com/google-gemini/gemini-cli/pull/17852) -- fix(cli): resolve environment loading and auth validation issues in ACP mode - by bdmorgan in - [#18025](https://github.com/google-gemini/gemini-cli/pull/18025) -- feat(core): add .agents/skills directory alias for skill discovery by + [#18498](https://github.com/google-gemini/gemini-cli/pull/18498) +- feat(core): overhaul system prompt for rigor, integrity, and intent alignment + by NTaylorMullen in + [#17263](https://github.com/google-gemini/gemini-cli/pull/17263) +- Patch for generate changelog docs yaml file by g-samroberts in + [#18496](https://github.com/google-gemini/gemini-cli/pull/18496) +- Code review fixes for show question mark pr. by jacob314 in + [#18480](https://github.com/google-gemini/gemini-cli/pull/18480) +- fix(cli): add SS3 Shift+Tab support for Windows terminals by ThanhNguyxn in + [#18187](https://github.com/google-gemini/gemini-cli/pull/18187) +- chore: remove redundant planning prompt from final shell by jerop in + [#18528](https://github.com/google-gemini/gemini-cli/pull/18528) +- docs: require pr-creator skill for PR generation by NTaylorMullen in + [#18536](https://github.com/google-gemini/gemini-cli/pull/18536) +- chore: update colors for ask_user dialog by jackwotherspoon in + [#18543](https://github.com/google-gemini/gemini-cli/pull/18543) +- feat(core): exempt high-signal tools from output masking by abhipatel12 in + [#18545](https://github.com/google-gemini/gemini-cli/pull/18545) +- refactor(core): remove memory tool instructions from Gemini 3 prompt by + NTaylorMullen in + [#18559](https://github.com/google-gemini/gemini-cli/pull/18559) +- chore: remove feedback instruction from system prompt by NTaylorMullen in + [#18560](https://github.com/google-gemini/gemini-cli/pull/18560) +- feat(context): add remote configuration for tool output masking thresholds by + abhipatel12 in + [#18553](https://github.com/google-gemini/gemini-cli/pull/18553) +- feat(core): pause agent timeout budget while waiting for tool confirmation by + abhipatel12 in + [#18415](https://github.com/google-gemini/gemini-cli/pull/18415) +- refactor(config): remove experimental.enableEventDrivenScheduler setting by + abhipatel12 in + [#17924](https://github.com/google-gemini/gemini-cli/pull/17924) +- feat(cli): truncate shell output in UI history and improve active shell + display by jwhelangoog in + [#17438](https://github.com/google-gemini/gemini-cli/pull/17438) +- refactor(cli): switch useToolScheduler to event-driven engine by abhipatel12 + in [#18565](https://github.com/google-gemini/gemini-cli/pull/18565) +- fix(core): correct escaped interpolation in system prompt by NTaylorMullen in + [#18557](https://github.com/google-gemini/gemini-cli/pull/18557) +- propagate abortSignal by scidomino in + [#18477](https://github.com/google-gemini/gemini-cli/pull/18477) +- feat(core): conditionally include ctrl+f prompt based on interactive shell + setting by NTaylorMullen in + [#18561](https://github.com/google-gemini/gemini-cli/pull/18561) +- fix(core): ensure enter_plan_mode tool registration respects experimental.plan + by jerop in [#18587](https://github.com/google-gemini/gemini-cli/pull/18587) +- feat(core): transition sub-agents to XML format and improve definitions by + NTaylorMullen in + [#18555](https://github.com/google-gemini/gemini-cli/pull/18555) +- docs: Add Plan Mode documentation by jerop in + [#18582](https://github.com/google-gemini/gemini-cli/pull/18582) +- chore: strengthen validation guidance in system prompt by NTaylorMullen in + [#18544](https://github.com/google-gemini/gemini-cli/pull/18544) +- Fix newline insertion bug in replace tool by werdnum in + [#18595](https://github.com/google-gemini/gemini-cli/pull/18595) +- fix(evals): update save_memory evals and simplify tool description by + NTaylorMullen in + [#18610](https://github.com/google-gemini/gemini-cli/pull/18610) +- chore(evals): update validation_fidelity_pre_existing_errors to USUALLY_PASSES + by NTaylorMullen in + [#18617](https://github.com/google-gemini/gemini-cli/pull/18617) +- fix: shorten tool call IDs and fix duplicate tool name in truncated output + filenames by SandyTao520 in + [#18600](https://github.com/google-gemini/gemini-cli/pull/18600) +- feat(cli): implement atomic writes and safety checks for trusted folders by + galz10 in [#18406](https://github.com/google-gemini/gemini-cli/pull/18406) +- Remove relative docs links by chrstnb in + [#18650](https://github.com/google-gemini/gemini-cli/pull/18650) +- docs: add legacy snippets convention to GEMINI.md by NTaylorMullen in + [#18597](https://github.com/google-gemini/gemini-cli/pull/18597) +- fix(chore): Support linting for cjs by aswinashok44 in + [#18639](https://github.com/google-gemini/gemini-cli/pull/18639) +- feat: move shell efficiency guidelines to tool description by NTaylorMullen in + [#18614](https://github.com/google-gemini/gemini-cli/pull/18614) +- Added "" as default value, since getText() used to expect a string only and + thus crashed when undefined... Fixes #18076 by 019-Abhi in + [#18099](https://github.com/google-gemini/gemini-cli/pull/18099) +- Allow @-includes outside of workspaces (with permission) by scidomino in + [#18470](https://github.com/google-gemini/gemini-cli/pull/18470) +- chore: make ask_user header description more clear by jackwotherspoon in + [#18657](https://github.com/google-gemini/gemini-cli/pull/18657) +- refactor(core): model-dependent tool definitions by aishaneeshah in + [#18563](https://github.com/google-gemini/gemini-cli/pull/18563) +- Harded code assist converter. by jacob314 in + [#18656](https://github.com/google-gemini/gemini-cli/pull/18656) +- bug(core): Fix minor bug in migration logic. by joshualitt in + [#18661](https://github.com/google-gemini/gemini-cli/pull/18661) +- feat: enable plan mode experiment in settings by jerop in + [#18636](https://github.com/google-gemini/gemini-cli/pull/18636) +- refactor: push isValidPath() into parsePastedPaths() by scidomino in + [#18664](https://github.com/google-gemini/gemini-cli/pull/18664) +- fix(cli): correct 'esc to cancel' position and restore duration display by + NTaylorMullen in + [#18534](https://github.com/google-gemini/gemini-cli/pull/18534) +- feat(cli): add DevTools integration with gemini-cli-devtools by SandyTao520 in + [#18648](https://github.com/google-gemini/gemini-cli/pull/18648) +- chore: remove unused exports and redundant hook files by SandyTao520 in + [#18681](https://github.com/google-gemini/gemini-cli/pull/18681) +- Fix number of lines being reported in rewind confirmation dialog by Adib234 in + [#18675](https://github.com/google-gemini/gemini-cli/pull/18675) +- feat(cli): disable folder trust in headless mode by galz10 in + [#18407](https://github.com/google-gemini/gemini-cli/pull/18407) +- Disallow unsafe type assertions by gundermanc in + [#18688](https://github.com/google-gemini/gemini-cli/pull/18688) +- Change event type for release by g-samroberts in + [#18693](https://github.com/google-gemini/gemini-cli/pull/18693) +- feat: handle multiple dynamic context filenames in system prompt by + NTaylorMullen in + [#18598](https://github.com/google-gemini/gemini-cli/pull/18598) +- Properly parse at-commands with narrow non-breaking spaces by scidomino in + [#18677](https://github.com/google-gemini/gemini-cli/pull/18677) +- refactor(core): centralize core tool definitions and support model-specific + schemas by aishaneeshah in + [#18662](https://github.com/google-gemini/gemini-cli/pull/18662) +- feat(core): Render memory hierarchically in context. by joshualitt in + [#18350](https://github.com/google-gemini/gemini-cli/pull/18350) +- feat: Ctrl+O to expand paste placeholder by jackwotherspoon in + [#18103](https://github.com/google-gemini/gemini-cli/pull/18103) +- fix(cli): Improve header spacing by NTaylorMullen in + [#18531](https://github.com/google-gemini/gemini-cli/pull/18531) +- Feature/quota visibility 16795 by spencer426 in + [#18203](https://github.com/google-gemini/gemini-cli/pull/18203) +- Inline thinking bubbles with summary/full modes by LyalinDotCom in + [#18033](https://github.com/google-gemini/gemini-cli/pull/18033) +- docs: remove TOC marker from Plan Mode header by jerop in + [#18678](https://github.com/google-gemini/gemini-cli/pull/18678) +- fix(ui): remove redundant newlines in Gemini messages by NTaylorMullen in + [#18538](https://github.com/google-gemini/gemini-cli/pull/18538) +- test(cli): fix AppContainer act() warnings and improve waitFor resilience by + NTaylorMullen in + [#18676](https://github.com/google-gemini/gemini-cli/pull/18676) +- refactor(core): refine Security & System Integrity section in system prompt by + NTaylorMullen in + [#18601](https://github.com/google-gemini/gemini-cli/pull/18601) +- Fix layout rounding. by gundermanc in + [#18667](https://github.com/google-gemini/gemini-cli/pull/18667) +- docs(skills): enhance pr-creator safety and interactivity by NTaylorMullen in + [#18616](https://github.com/google-gemini/gemini-cli/pull/18616) +- test(core): remove hardcoded model from TestRig by NTaylorMullen in + [#18710](https://github.com/google-gemini/gemini-cli/pull/18710) +- feat(core): optimize sub-agents system prompt intro by NTaylorMullen in + [#18608](https://github.com/google-gemini/gemini-cli/pull/18608) +- feat(cli): update approval mode labels and shortcuts per latest UX spec by + jerop in [#18698](https://github.com/google-gemini/gemini-cli/pull/18698) +- fix(plan): update persistent approval mode setting by Adib234 in + [#18638](https://github.com/google-gemini/gemini-cli/pull/18638) +- fix: move toasts location to left side by jackwotherspoon in + [#18705](https://github.com/google-gemini/gemini-cli/pull/18705) +- feat(routing): restrict numerical routing to Gemini 3 family by mattKorwel in + [#18478](https://github.com/google-gemini/gemini-cli/pull/18478) +- fix(ide): fix ide nudge setting by skeshive in + [#18733](https://github.com/google-gemini/gemini-cli/pull/18733) +- fix(core): standardize tool formatting in system prompts by NTaylorMullen in + [#18615](https://github.com/google-gemini/gemini-cli/pull/18615) +- chore: consolidate to green in ask user dialog by jackwotherspoon in + [#18734](https://github.com/google-gemini/gemini-cli/pull/18734) +- feat: add extensionsExplore setting to enable extensions explore UI. by + sripasg in [#18686](https://github.com/google-gemini/gemini-cli/pull/18686) +- feat(cli): defer devtools startup and integrate with F12 by SandyTao520 in + [#18695](https://github.com/google-gemini/gemini-cli/pull/18695) +- ui: update & subdue footer colors and animate progress indicator by + keithguerin in + [#18570](https://github.com/google-gemini/gemini-cli/pull/18570) +- test: add model-specific snapshots for coreTools by aishaneeshah in + [#18707](https://github.com/google-gemini/gemini-cli/pull/18707) +- ci: shard windows tests and fix event listener leaks by NTaylorMullen in + [#18670](https://github.com/google-gemini/gemini-cli/pull/18670) +- fix: allow ask_user tool in yolo mode by jackwotherspoon in + [#18541](https://github.com/google-gemini/gemini-cli/pull/18541) +- feat: redact disabled tools from system prompt + ([#13597](https://github.com/google-gemini/gemini-cli/pull/13597)) by NTaylorMullen in - [#18151](https://github.com/google-gemini/gemini-cli/pull/18151) -- chore(core): reassign telemetry keys to avoid server conflict by mattKorwel in - [#18161](https://github.com/google-gemini/gemini-cli/pull/18161) -- Add link to rewind doc in commands.md by Adib234 in - [#17961](https://github.com/google-gemini/gemini-cli/pull/17961) -- feat(core): add draft-2020-12 JSON Schema support with lenient fallback by - afarber in [#15060](https://github.com/google-gemini/gemini-cli/pull/15060) -- refactor(core): robust trimPreservingTrailingNewline and regression test by + [#18613](https://github.com/google-gemini/gemini-cli/pull/18613) +- Update Gemini.md to use the curent year on creating new files by sehoon38 in + [#18460](https://github.com/google-gemini/gemini-cli/pull/18460) +- Code review cleanup for thinking display by jacob314 in + [#18720](https://github.com/google-gemini/gemini-cli/pull/18720) +- fix(cli): hide scrollbars when in alternate buffer copy mode by werdnum in + [#18354](https://github.com/google-gemini/gemini-cli/pull/18354) +- Fix issues with rip grep by gundermanc in + [#18756](https://github.com/google-gemini/gemini-cli/pull/18756) +- fix(cli): fix history navigation regression after prompt autocomplete by + sehoon38 in [#18752](https://github.com/google-gemini/gemini-cli/pull/18752) +- chore: cleanup unused and add unlisted dependencies in packages/cli by adamfweidman in - [#18196](https://github.com/google-gemini/gemini-cli/pull/18196) -- Remove MCP servers on extension uninstall by chrstnb in - [#18121](https://github.com/google-gemini/gemini-cli/pull/18121) -- refactor: localize ACP error parsing logic to cli package by bdmorgan in - [#18193](https://github.com/google-gemini/gemini-cli/pull/18193) -- feat(core): Add A2A auth config types by adamfweidman in - [#18205](https://github.com/google-gemini/gemini-cli/pull/18205) -- Set default max attempts to 3 and use the common variable by sehoon38 in - [#18209](https://github.com/google-gemini/gemini-cli/pull/18209) -- feat(plan): add exit_plan_mode ui and prompt by jerop in - [#18162](https://github.com/google-gemini/gemini-cli/pull/18162) -- fix(test): improve test isolation and enable subagent evaluations by - cocosheng-g in - [#18138](https://github.com/google-gemini/gemini-cli/pull/18138) -- feat(plan): use custom deny messages in plan mode policies by Adib234 in - [#18195](https://github.com/google-gemini/gemini-cli/pull/18195) -- Match on extension ID when stopping extensions by chrstnb in - [#18218](https://github.com/google-gemini/gemini-cli/pull/18218) -- fix(core): Respect user's .gitignore preference by xyrolle in - [#15482](https://github.com/google-gemini/gemini-cli/pull/15482) -- docs: document GEMINI_CLI_HOME environment variable by adamfweidman in - [#18219](https://github.com/google-gemini/gemini-cli/pull/18219) -- chore(core): explicitly state plan storage path in prompt by jerop in - [#18222](https://github.com/google-gemini/gemini-cli/pull/18222) -- A2a admin setting by DavidAPierce in - [#17868](https://github.com/google-gemini/gemini-cli/pull/17868) -- feat(a2a): Add pluggable auth provider infrastructure by adamfweidman in - [#17934](https://github.com/google-gemini/gemini-cli/pull/17934) -- Fix handling of empty settings by chrstnb in - [#18131](https://github.com/google-gemini/gemini-cli/pull/18131) -- Reload skills when extensions change by chrstnb in - [#18225](https://github.com/google-gemini/gemini-cli/pull/18225) -- feat: Add markdown rendering to ask_user tool by jackwotherspoon in - [#18211](https://github.com/google-gemini/gemini-cli/pull/18211) -- Add telemetry to rewind by Adib234 in - [#18122](https://github.com/google-gemini/gemini-cli/pull/18122) -- feat(admin): add support for MCP configuration via admin controls (pt1) by - skeshive in [#18223](https://github.com/google-gemini/gemini-cli/pull/18223) -- feat(core): require user consent before MCP server OAuth by ehedlund in - [#18132](https://github.com/google-gemini/gemini-cli/pull/18132) -- fix(sandbox): propagate GOOGLE_GEMINI_BASE_URL&GOOGLE_VERTEX_BASE_URL env vars - by skeshive in - [#18231](https://github.com/google-gemini/gemini-cli/pull/18231) -- feat(ui): move user identity display to header by sehoon38 in - [#18216](https://github.com/google-gemini/gemini-cli/pull/18216) -- fix: enforce folder trust for workspace settings, skills, and context by - galz10 in [#17596](https://github.com/google-gemini/gemini-cli/pull/17596) + [#18749](https://github.com/google-gemini/gemini-cli/pull/18749) +- Fix issue where Gemini CLI creates tests in a new file by gundermanc in + [#18409](https://github.com/google-gemini/gemini-cli/pull/18409) +- feat(telemetry): Ensure experiment IDs are included in OpenTelemetry logs by + kevin-ramdass in + [#18747](https://github.com/google-gemini/gemini-cli/pull/18747) **Full changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.27.0-preview.8...v0.28.0-preview.0 +https://github.com/google-gemini/gemini-cli/compare/v0.28.0-preview.0...v0.29.0-preview.0 diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index d1094a15e20..8199445625d 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -27,29 +27,29 @@ and parameters. ## CLI Options -| Option | Alias | Type | Default | Description | -| -------------------------------- | ----- | ------- | --------- | ---------------------------------------------------------------------------------------------------------- | -| `--debug` | `-d` | boolean | `false` | Run in debug mode with verbose logging | -| `--version` | `-v` | - | - | Show CLI version number and exit | -| `--help` | `-h` | - | - | Show help information | -| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. | -| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. **Deprecated:** Use positional arguments instead. | -| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | -| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | -| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` | -| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. | -| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** | -| `--experimental-zed-integration` | - | boolean | - | Run in Zed editor integration mode. **Experimental feature.** | -| `--allowed-mcp-server-names` | - | array | - | Allowed MCP server names (comma-separated or multiple flags) | -| `--allowed-tools` | - | array | - | Tools that are allowed to run without confirmation (comma-separated or multiple flags) | -| `--extensions` | `-e` | array | - | List of extensions to use. If not provided, all extensions are enabled (comma-separated or multiple flags) | -| `--list-extensions` | `-l` | boolean | - | List all available extensions and exit | -| `--resume` | `-r` | string | - | Resume a previous session. Use `"latest"` for most recent or index number (e.g. `--resume 5`) | -| `--list-sessions` | - | boolean | - | List available sessions for the current project and exit | -| `--delete-session` | - | string | - | Delete a session by index number (use `--list-sessions` to see available sessions) | -| `--include-directories` | - | array | - | Additional directories to include in the workspace (comma-separated or multiple flags) | -| `--screen-reader` | - | boolean | - | Enable screen reader mode for accessibility | -| `--output-format` | `-o` | string | `text` | The format of the CLI output. Choices: `text`, `json`, `stream-json` | +| Option | Alias | Type | Default | Description | +| -------------------------------- | ----- | ------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--debug` | `-d` | boolean | `false` | Run in debug mode with verbose logging | +| `--version` | `-v` | - | - | Show CLI version number and exit | +| `--help` | `-h` | - | - | Show help information | +| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. | +| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. **Deprecated:** Use positional arguments instead. | +| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | +| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | +| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` | +| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. | +| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** | +| `--experimental-zed-integration` | - | boolean | - | Run in Zed editor integration mode. **Experimental feature.** | +| `--allowed-mcp-server-names` | - | array | - | Allowed MCP server names (comma-separated or multiple flags) | +| `--allowed-tools` | - | array | - | **Deprecated.** Use the [Policy Engine](../core/policy-engine.md) instead. Tools that are allowed to run without confirmation (comma-separated or multiple flags) | +| `--extensions` | `-e` | array | - | List of extensions to use. If not provided, all extensions are enabled (comma-separated or multiple flags) | +| `--list-extensions` | `-l` | boolean | - | List all available extensions and exit | +| `--resume` | `-r` | string | - | Resume a previous session. Use `"latest"` for most recent or index number (e.g. `--resume 5`) | +| `--list-sessions` | - | boolean | - | List available sessions for the current project and exit | +| `--delete-session` | - | string | - | Delete a session by index number (use `--list-sessions` to see available sessions) | +| `--include-directories` | - | array | - | Additional directories to include in the workspace (comma-separated or multiple flags) | +| `--screen-reader` | - | boolean | - | Enable screen reader mode for accessibility | +| `--output-format` | `-o` | string | `text` | The format of the CLI output. Choices: `text`, `json`, `stream-json` | ## Model selection diff --git a/docs/cli/enterprise.md b/docs/cli/enterprise.md index f22ec81c37c..861fc68c715 100644 --- a/docs/cli/enterprise.md +++ b/docs/cli/enterprise.md @@ -223,9 +223,9 @@ gemini ## Restricting tool access You can significantly enhance security by controlling which tools the Gemini -model can use. This is achieved through the `tools.core` and `tools.exclude` -settings. For a list of available tools, see the -[Tools documentation](../tools/index.md). +model can use. This is achieved through the `tools.core` setting and the +[Policy Engine](../core/policy-engine.md). For a list of available tools, see +the [Tools documentation](../tools/index.md). ### Allowlisting with `coreTools` @@ -243,7 +243,10 @@ on the approved list. } ``` -### Blocklisting with `excludeTools` +### Blocklisting with `excludeTools` (Deprecated) + +> **Deprecated:** Use the [Policy Engine](../core/policy-engine.md) for more +> robust control. Alternatively, you can add specific tools that are considered dangerous in your environment to a blocklist. diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 0d6b72206e2..751794996b2 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -30,6 +30,8 @@ implementation strategy. - [The Planning Workflow](#the-planning-workflow) - [Exiting Plan Mode](#exiting-plan-mode) - [Tool Restrictions](#tool-restrictions) + - [Customizing Planning with Skills](#customizing-planning-with-skills) + - [Customizing Policies](#customizing-policies) ## Starting in Plan Mode @@ -61,11 +63,12 @@ You can enter Plan Mode in three ways: 1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes (`Default` -> `Plan` -> `Auto-Edit`). 2. **Command:** Type `/plan` in the input box. -3. **Natural Language:** Ask the agent to "start a plan for...". +3. **Natural Language:** Ask the agent to "start a plan for...". The agent will + then call the [`enter_plan_mode`] tool to switch modes. ### The Planning Workflow -1. **Requirements:** The agent clarifies goals using `ask_user`. +1. **Requirements:** The agent clarifies goals using [`ask_user`]. 2. **Exploration:** The agent uses read-only tools (like [`read_file`]) to map the codebase and validate assumptions. 3. **Design:** The agent proposes alternative approaches with a recommended @@ -81,8 +84,8 @@ You can enter Plan Mode in three ways: To exit Plan Mode: 1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode. -1. **Tool:** The agent calls the `exit_plan_mode` tool to present the finalized - plan for your approval. +2. **Tool:** The agent calls the [`exit_plan_mode`] tool to present the + finalized plan for your approval. ## Tool Restrictions @@ -92,11 +95,80 @@ These are the only allowed tools: - **FileSystem (Read):** [`read_file`], [`list_directory`], [`glob`] - **Search:** [`grep_search`], [`google_web_search`] -- **Interaction:** `ask_user` +- **Interaction:** [`ask_user`] - **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`, `postgres_read_schema`) are allowed. - **Planning (Write):** [`write_file`] and [`replace`] ONLY allowed for `.md` files in the `~/.gemini/tmp//plans/` directory. +- **Skills:** [`activate_skill`] (allows loading specialized instructions and + resources in a read-only manner) + +### Customizing Planning with Skills + +You can leverage [Agent Skills](./skills.md) to customize how Gemini CLI +approaches planning for specific types of tasks. When a skill is activated +during Plan Mode, its specialized instructions and procedural workflows will +guide the research and design phases. + +For example: + +- A **"Database Migration"** skill could ensure the plan includes data safety + checks and rollback strategies. +- A **"Security Audit"** skill could prompt the agent to look for specific + vulnerabilities during codebase exploration. +- A **"Frontend Design"** skill could guide the agent to use specific UI + components and accessibility standards in its proposal. + +To use a skill in Plan Mode, you can explicitly ask the agent to "use the +[skill-name] skill to plan..." or the agent may autonomously activate it based +on the task description. + +### Customizing Policies + +Plan Mode is designed to be read-only by default to ensure safety during the +research phase. However, you may occasionally need to allow specific tools to +assist in your planning. + +Because user policies (Tier 2) have a higher base priority than built-in +policies (Tier 1), you can override Plan Mode's default restrictions by creating +a rule in your `~/.gemini/policies/` directory. + +#### Example: Allow `git status` and `git diff` in Plan Mode + +This rule allows you to check the repository status and see changes while in +Plan Mode. + +`~/.gemini/policies/git-research.toml` + +```toml +[[rule]] +toolName = "run_shell_command" +commandPrefix = ["git status", "git diff"] +decision = "allow" +priority = 100 +modes = ["plan"] +``` + +#### Example: Enable research sub-agents in Plan Mode + +You can enable [experimental research sub-agents] like `codebase_investigator` +to help gather architecture details during the planning phase. + +`~/.gemini/policies/research-subagents.toml` + +```toml +[[rule]] +toolName = "codebase_investigator" +decision = "allow" +priority = 100 +modes = ["plan"] +``` + +Tell the agent it can use these tools in your prompt, for example: _"You can +check ongoing changes in git."_ + +For more information on how the policy engine works, see the [Policy Engine +Guide]. [`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder [`read_file`]: /docs/tools/file-system.md#2-read_file-readfile @@ -106,3 +178,9 @@ These are the only allowed tools: [`google_web_search`]: /docs/tools/web-search.md [`replace`]: /docs/tools/file-system.md#6-replace-edit [MCP tools]: /docs/tools/mcp-server.md +[`activate_skill`]: /docs/cli/skills.md +[experimental research sub-agents]: /docs/core/subagents.md +[Policy Engine Guide]: /docs/core/policy-engine.md +[`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode +[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode +[`ask_user`]: /docs/tools/ask-user.md diff --git a/docs/core/policy-engine.md b/docs/core/policy-engine.md index f09ca01b70d..a99a6652d8f 100644 --- a/docs/core/policy-engine.md +++ b/docs/core/policy-engine.md @@ -119,9 +119,17 @@ For example: Approval modes allow the policy engine to apply different sets of rules based on the CLI's operational mode. A rule can be associated with one or more modes -(e.g., `yolo`, `autoEdit`). The rule will only be active if the CLI is running -in one of its specified modes. If a rule has no modes specified, it is always -active. +(e.g., `yolo`, `autoEdit`, `plan`). The rule will only be active if the CLI is +running in one of its specified modes. If a rule has no modes specified, it is +always active. + +- `default`: The standard interactive mode where most write tools require + confirmation. +- `autoEdit`: Optimized for automated code editing; some write tools may be + auto-approved. +- `plan`: A strict, read-only mode for research and design. See [Customizing + Plan Mode Policies]. +- `yolo`: A mode where all tools are auto-approved (use with extreme caution). ## Rule matching @@ -303,3 +311,5 @@ out-of-the-box experience. - In **`yolo`** mode, a high-priority rule allows all tools. - In **`autoEdit`** mode, rules allow certain write operations to happen without prompting. + +[Customizing Plan Mode Policies]: /docs/cli/plan-mode.md#customizing-policies diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index 7b5f782917e..c969e7a3f87 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -179,9 +179,6 @@ precedence. ### Settings -_Note: This is an experimental feature. We do not yet recommend extension -authors introduce settings as part of their core flows._ - Extensions can define settings that the user will be prompted to provide upon installation. This is useful for things like API keys, URLs, or other configuration that the extension needs to function. diff --git a/docs/get-started/configuration-v1.md b/docs/get-started/configuration-v1.md index 050dce32b6c..cd1325b977f 100644 --- a/docs/get-started/configuration-v1.md +++ b/docs/get-started/configuration-v1.md @@ -166,19 +166,21 @@ a few things you can try in order of recommendation: - **Default:** All tools available for use by the Gemini model. - **Example:** `"coreTools": ["ReadFileTool", "GlobTool", "ShellTool(ls)"]`. -- **`allowedTools`** (array of strings): +- **`allowedTools`** (array of strings) [DEPRECATED]: - **Default:** `undefined` - **Description:** A list of tool names that will bypass the confirmation dialog. This is useful for tools that you trust and use frequently. The - match semantics are the same as `coreTools`. + match semantics are the same as `coreTools`. **Deprecated**: Use the + [Policy Engine](../core/policy-engine.md) instead. - **Example:** `"allowedTools": ["ShellTool(git status)"]`. -- **`excludeTools`** (array of strings): +- **`excludeTools`** (array of strings) [DEPRECATED]: - **Description:** Allows you to specify a list of core tool names that should be excluded from the model. A tool listed in both `excludeTools` and `coreTools` is excluded. You can also specify command-specific restrictions for tools that support it, like the `ShellTool`. For example, `"excludeTools": ["ShellTool(rm -rf)"]` will block the `rm -rf` command. + **Deprecated**: Use the [Policy Engine](../core/policy-engine.md) instead. - **Default**: No tools excluded. - **Example:** `"excludeTools": ["run_shell_command", "findFiles"]`. - **Security Note:** Command-specific restrictions in `excludeTools` for diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 84818a59bef..263dd815a8d 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -447,6 +447,12 @@ their corresponding top-level category object in your `settings.json` file. "model": "gemini-2.5-flash" } }, + "gemini-3-flash-base": { + "extends": "base", + "modelConfig": { + "model": "gemini-3-flash-preview" + } + }, "classifier": { "extends": "base", "modelConfig": { @@ -502,7 +508,7 @@ their corresponding top-level category object in your `settings.json` file. } }, "web-search": { - "extends": "gemini-2.5-flash-base", + "extends": "gemini-3-flash-base", "modelConfig": { "generateContentConfig": { "tools": [ @@ -514,7 +520,7 @@ their corresponding top-level category object in your `settings.json` file. } }, "web-fetch": { - "extends": "gemini-2.5-flash-base", + "extends": "gemini-3-flash-base", "modelConfig": { "generateContentConfig": { "tools": [ @@ -526,25 +532,25 @@ their corresponding top-level category object in your `settings.json` file. } }, "web-fetch-fallback": { - "extends": "gemini-2.5-flash-base", + "extends": "gemini-3-flash-base", "modelConfig": {} }, "loop-detection": { - "extends": "gemini-2.5-flash-base", + "extends": "gemini-3-flash-base", "modelConfig": {} }, "loop-detection-double-check": { "extends": "base", "modelConfig": { - "model": "gemini-2.5-pro" + "model": "gemini-3-pro-preview" } }, "llm-edit-fixer": { - "extends": "gemini-2.5-flash-base", + "extends": "gemini-3-flash-base", "modelConfig": {} }, "next-speaker-checker": { - "extends": "gemini-2.5-flash-base", + "extends": "gemini-3-flash-base", "modelConfig": {} }, "chat-compression-3-pro": { @@ -574,7 +580,7 @@ their corresponding top-level category object in your `settings.json` file. }, "chat-compression-default": { "modelConfig": { - "model": "gemini-2.5-pro" + "model": "gemini-3-pro-preview" } } } diff --git a/docs/tools/ask-user.md b/docs/tools/ask-user.md new file mode 100644 index 00000000000..ad6c3b5a063 --- /dev/null +++ b/docs/tools/ask-user.md @@ -0,0 +1,95 @@ +# Ask User Tool + +The `ask_user` tool allows the agent to ask you one or more questions to gather +preferences, clarify requirements, or make decisions. It supports multiple +question types including multiple-choice, free-form text, and Yes/No +confirmation. + +## `ask_user` (Ask User) + +- **Tool name:** `ask_user` +- **Display name:** Ask User +- **File:** `ask-user.ts` +- **Parameters:** + - `questions` (array of objects, required): A list of 1 to 4 questions to ask. + Each question object has the following properties: + - `question` (string, required): The complete question text. + - `header` (string, required): A short label (max 16 chars) displayed as a + chip/tag (e.g., "Auth", "Database"). + - `type` (string, optional): The type of question. Defaults to `'choice'`. + - `'choice'`: Multiple-choice with options (supports multi-select). + - `'text'`: Free-form text input. + - `'yesno'`: Yes/No confirmation. + - `options` (array of objects, optional): Required for `'choice'` type. 2-4 + selectable options. + - `label` (string, required): Display text (1-5 words). + - `description` (string, required): Brief explanation. + - `multiSelect` (boolean, optional): For `'choice'` type, allows selecting + multiple options. + - `placeholder` (string, optional): Hint text for input fields. + +- **Behavior:** + - Presents an interactive dialog to the user with the specified questions. + - Pauses execution until the user provides answers or dismisses the dialog. + - Returns the user's answers to the model. + +- **Output (`llmContent`):** A JSON string containing the user's answers, + indexed by question position (e.g., + `{"answers":{"0": "Option A", "1": "Some text"}}`). + +- **Confirmation:** Yes. The tool inherently involves user interaction. + +## Usage Examples + +### Multiple Choice Question + +```json +{ + "questions": [ + { + "header": "Database", + "question": "Which database would you like to use?", + "type": "choice", + "options": [ + { + "label": "PostgreSQL", + "description": "Powerful, open source object-relational database system." + }, + { + "label": "SQLite", + "description": "C-library that implements a SQL database engine." + } + ] + } + ] +} +``` + +### Text Input Question + +```json +{ + "questions": [ + { + "header": "Project Name", + "question": "What is the name of your new project?", + "type": "text", + "placeholder": "e.g., my-awesome-app" + } + ] +} +``` + +### Yes/No Question + +```json +{ + "questions": [ + { + "header": "Deploy", + "question": "Do you want to deploy the application now?", + "type": "yesno" + } + ] +} +``` diff --git a/docs/tools/index.md b/docs/tools/index.md index c21c3dc6108..c7b2c1fc725 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -86,6 +86,9 @@ Gemini CLI's built-in tools can be broadly categorized as follows: information across sessions. - **[Todo Tool](./todos.md) (`write_todos`):** For managing subtasks of complex requests. +- **[Planning Tools](./planning.md):** For entering and exiting Plan Mode. +- **[Ask User Tool](./ask-user.md) (`ask_user`):** For gathering user input and + making decisions. Additionally, these tools incorporate: diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index eb246fd86f5..dd3842759cd 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -739,21 +739,10 @@ The MCP integration tracks several states: cautiously and only for servers you completely control - **Access tokens:** Be security-aware when configuring environment variables containing API keys or tokens -- **Environment variable redaction:** By default, the Gemini CLI redacts - sensitive environment variables (such as `GEMINI_API_KEY`, `GOOGLE_API_KEY`, - and variables matching patterns like `*TOKEN*`, `*SECRET*`, `*PASSWORD*`) when - spawning MCP servers using the `stdio` transport. This prevents unintended - exposure of your credentials to third-party servers. -- **Explicit environment variables:** If you need to pass a specific environment - variable to an MCP server, you should define it explicitly in the `env` - property of the server configuration in `settings.json`. - **Sandbox compatibility:** When using sandboxing, ensure MCP servers are - available within the sandbox environment. + available within the sandbox environment - **Private data:** Using broadly scoped personal access tokens can lead to information leakage between repositories. -- **Untrusted servers:** Be extremely cautious when adding MCP servers from - untrusted or third-party sources. Malicious servers could attempt to - exfiltrate data or perform unauthorized actions through the tools they expose. ### Performance and resource management diff --git a/docs/tools/planning.md b/docs/tools/planning.md new file mode 100644 index 00000000000..686b27f0582 --- /dev/null +++ b/docs/tools/planning.md @@ -0,0 +1,55 @@ +# Gemini CLI planning tools + +Planning tools allow the Gemini model to switch into a safe, read-only "Plan +Mode" for researching and planning complex changes, and to signal the +finalization of a plan to the user. + +## 1. `enter_plan_mode` (EnterPlanMode) + +`enter_plan_mode` switches the CLI to Plan Mode. This tool is typically called +by the agent when you ask it to "start a plan" using natural language. In this +mode, the agent is restricted to read-only tools to allow for safe exploration +and planning. + +- **Tool name:** `enter_plan_mode` +- **Display name:** Enter Plan Mode +- **File:** `enter-plan-mode.ts` +- **Parameters:** + - `reason` (string, optional): A short reason explaining why the agent is + entering plan mode (e.g., "Starting a complex feature implementation"). +- **Behavior:** + - Switches the CLI's approval mode to `PLAN`. + - Notifies the user that the agent has entered Plan Mode. +- **Output (`llmContent`):** A message indicating the switch, e.g., + `Switching to Plan mode.` +- **Confirmation:** Yes. The user is prompted to confirm entering Plan Mode. + +## 2. `exit_plan_mode` (ExitPlanMode) + +`exit_plan_mode` signals that the planning phase is complete. It presents the +finalized plan to the user and requests approval to start the implementation. + +- **Tool name:** `exit_plan_mode` +- **Display name:** Exit Plan Mode +- **File:** `exit-plan-mode.ts` +- **Parameters:** + - `plan_path` (string, required): The path to the finalized Markdown plan + file. This file MUST be located within the project's temporary plans + directory (e.g., `~/.gemini/tmp//plans/`). +- **Behavior:** + - Validates that the `plan_path` is within the allowed directory and that the + file exists and has content. + - Presents the plan to the user for review. + - If the user approves the plan: + - Switches the CLI's approval mode to the user's chosen approval mode ( + `DEFAULT` or `AUTO_EDIT`). + - Marks the plan as approved for implementation. + - If the user rejects the plan: + - Stays in Plan Mode. + - Returns user feedback to the model to refine the plan. +- **Output (`llmContent`):** + - On approval: A message indicating the plan was approved and the new approval + mode. + - On rejection: A message containing the user's feedback. +- **Confirmation:** Yes. Shows the finalized plan and asks for user approval to + proceed with implementation. diff --git a/docs/tools/shell.md b/docs/tools/shell.md index 0bb4b682442..48854e82f1e 100644 --- a/docs/tools/shell.md +++ b/docs/tools/shell.md @@ -167,10 +167,11 @@ configuration file. `"tools": {"core": ["run_shell_command(git)"]}` will only allow `git` commands. Including the generic `run_shell_command` acts as a wildcard, allowing any command not explicitly blocked. -- `tools.exclude`: To block specific commands, add entries to the `exclude` list - under the `tools` category in the format `run_shell_command()`. For - example, `"tools": {"exclude": ["run_shell_command(rm)"]}` will block `rm` - commands. +- `tools.exclude` [DEPRECATED]: To block specific commands, use the + [Policy Engine](../core/policy-engine.md). Historically, this setting allowed + adding entries to the `exclude` list under the `tools` category in the format + `run_shell_command()`. For example, + `"tools": {"exclude": ["run_shell_command(rm)"]}` will block `rm` commands. The validation logic is designed to be secure and flexible: diff --git a/evals/frugalSearch.eval.ts b/evals/frugalSearch.eval.ts new file mode 100644 index 00000000000..11c51e85298 --- /dev/null +++ b/evals/frugalSearch.eval.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { evalTest } from './test-helper.js'; + +/** + * Evals to verify that the agent uses search tools efficiently (frugally) + * by utilizing limiting parameters like `total_max_matches` and `max_matches_per_file`. + * This ensures the agent doesn't flood the context window with unnecessary search results. + */ +describe('Frugal Search', () => { + const getGrepParams = (call: any): any => { + let args = call.toolRequest.args; + if (typeof args === 'string') { + try { + args = JSON.parse(args); + } catch (e) { + // Ignore parse errors + } + } + return args; + }; + + evalTest('USUALLY_PASSES', { + name: 'should use targeted search with limit', + prompt: 'find me a sample usage of path.resolve() in the codebase', + files: { + 'package.json': JSON.stringify({ + name: 'test-project', + version: '1.0.0', + main: 'dist/index.js', + scripts: { + build: 'tsc', + test: 'vitest', + }, + dependencies: { + typescript: '^5.0.0', + '@types/node': '^20.0.0', + vitest: '^1.0.0', + }, + }), + 'src/index.ts': ` + import { App } from './app.ts'; + + const app = new App(); + app.start(); + `, + 'src/app.ts': ` + import * as path from 'path'; + import { UserController } from './controllers/user.ts'; + + export class App { + constructor() { + console.log('App initialized'); + } + + public start(): void { + const userController = new UserController(); + console.log('Static path:', path.resolve(__dirname, '../public')); + } + } + `, + 'src/utils.ts': ` + import * as path from 'path'; + import * as fs from 'fs'; + + export function resolvePath(p: string): string { + return path.resolve(process.cwd(), p); + } + + export function ensureDir(dirPath: string): void { + const absolutePath = path.resolve(dirPath); + if (!fs.existsSync(absolutePath)) { + fs.mkdirSync(absolutePath, { recursive: true }); + } + } + `, + 'src/config.ts': ` + import * as path from 'path'; + + export const config = { + dbPath: path.resolve(process.cwd(), 'data/db.sqlite'), + logLevel: 'info', + }; + `, + 'src/controllers/user.ts': ` + import * as path from 'path'; + + export class UserController { + public getUsers(): any[] { + console.log('Loading users from:', path.resolve('data/users.json')); + return [{ id: 1, name: 'Alice' }]; + } + } + `, + 'tests/app.test.ts': ` + import { describe, it, expect } from 'vitest'; + import * as path from 'path'; + + describe('App', () => { + it('should resolve paths', () => { + const p = path.resolve('test'); + expect(p).toBeDefined(); + }); + }); + `, + }, + assert: async (rig) => { + const toolCalls = rig.readToolLogs(); + const grepCalls = toolCalls.filter( + (call) => call.toolRequest.name === 'grep_search', + ); + + expect(grepCalls.length).toBeGreaterThan(0); + + const grepParams = grepCalls.map(getGrepParams); + + const hasTotalMaxLimit = grepParams.some( + (p) => p.total_max_matches !== undefined && p.total_max_matches <= 100, + ); + expect( + hasTotalMaxLimit, + `Expected agent to use a small total_max_matches (<= 100) for a sample usage request. Actual values: ${JSON.stringify( + grepParams.map((p) => p.total_max_matches), + )}`, + ).toBe(true); + + const hasMaxMatchesPerFileLimit = grepParams.some( + (p) => + p.max_matches_per_file !== undefined && p.max_matches_per_file <= 5, + ); + expect( + hasMaxMatchesPerFileLimit, + `Expected agent to use a small max_matches_per_file (<= 5) for a sample usage request. Actual values: ${JSON.stringify( + grepParams.map((p) => p.max_matches_per_file), + )}`, + ).toBe(true); + }, + }); +}); diff --git a/packages/cli/src/commands/mcp/add.ts b/packages/cli/src/commands/mcp/add.ts index 7d744a1daa5..98e6a708797 100644 --- a/packages/cli/src/commands/mcp/add.ts +++ b/packages/cli/src/commands/mcp/add.ts @@ -128,13 +128,6 @@ async function addMcpServer( settings.setValue(settingsScope, 'mcpServers', mcpServers); - if (transport === 'stdio') { - debugLogger.warn( - 'Security Warning: Running MCP servers with stdio transport can expose inherited environment variables. ' + - 'While the Gemini CLI redacts common API keys and secrets by default, you should only run servers from trusted sources.', - ); - } - if (isExistingServer) { debugLogger.log(`MCP server "${name}" updated in ${scope} settings.`); } else { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 87eb1e8fa7d..ea7d6f72a5c 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -177,7 +177,8 @@ export async function parseArguments( type: 'array', string: true, nargs: 1, - description: 'Tools that are allowed to run without confirmation', + description: + '[DEPRECATED: Use Policy Engine instead See https://geminicli.com/docs/core/policy-engine] Tools that are allowed to run without confirmation', coerce: (tools: string[]) => // Handle comma-separated values tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), @@ -445,7 +446,11 @@ export async function loadCliConfig( process.env['VITEST'] === 'true' ? false : (settings.security?.folderTrust?.enabled ?? false); - const trustedFolder = isWorkspaceTrusted(settings, cwd)?.isTrusted ?? false; + const trustedFolder = + isWorkspaceTrusted(settings, cwd, undefined, { + prompt: argv.prompt, + query: argv.query, + })?.isTrusted ?? false; // Set the context filename in the server's memoryTool module BEFORE loading memory // TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed @@ -602,8 +607,7 @@ export async function loadCliConfig( const interactive = !!argv.promptInteractive || !!argv.experimentalAcp || - (!isHeadlessMode({ prompt: argv.prompt }) && - !argv.query && + (!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) && !argv.isCommand); const allowedTools = argv.allowedTools || settings.tools?.allowed || []; diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 721458952fa..e88c9104ddf 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -2546,6 +2546,50 @@ describe('Settings Loading and Merging', () => { }); }); + describe('Reactivity & Snapshots', () => { + let loadedSettings: LoadedSettings; + + beforeEach(() => { + const emptySettingsFile: SettingsFile = { + path: '/mock/path', + settings: {}, + originalSettings: {}, + }; + + loadedSettings = new LoadedSettings( + { ...emptySettingsFile, path: getSystemSettingsPath() }, + { ...emptySettingsFile, path: getSystemDefaultsPath() }, + { ...emptySettingsFile, path: USER_SETTINGS_PATH }, + { ...emptySettingsFile, path: MOCK_WORKSPACE_SETTINGS_PATH }, + true, // isTrusted + [], + ); + }); + + it('getSnapshot() should return stable reference if no changes occur', () => { + const snap1 = loadedSettings.getSnapshot(); + const snap2 = loadedSettings.getSnapshot(); + expect(snap1).toBe(snap2); + }); + + it('setValue() should create a new snapshot reference and emit event', () => { + const oldSnapshot = loadedSettings.getSnapshot(); + const oldUserRef = oldSnapshot.user.settings; + + loadedSettings.setValue(SettingScope.User, 'ui.theme', 'high-contrast'); + + const newSnapshot = loadedSettings.getSnapshot(); + + expect(newSnapshot).not.toBe(oldSnapshot); + expect(newSnapshot.user.settings).not.toBe(oldUserRef); + expect(newSnapshot.user.settings.ui?.theme).toBe('high-contrast'); + + expect(newSnapshot.system.settings).not.toBe(oldSnapshot.system.settings); + + expect(mockCoreEvents.emitSettingsChanged).toHaveBeenCalled(); + }); + }); + describe('Security and Sandbox', () => { let originalArgv: string[]; let originalEnv: NodeJS.ProcessEnv; diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 8e9ff7380f9..b2b526a0101 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -10,6 +10,7 @@ import { platform } from 'node:os'; import * as dotenv from 'dotenv'; import process from 'node:process'; import { + CoreEvent, FatalConfigError, GEMINI_DIR, getErrorMessage, @@ -284,6 +285,20 @@ export function createTestMergedSettings( ) as MergedSettings; } +/** + * An immutable snapshot of settings state. + * Used with useSyncExternalStore for reactive updates. + */ +export interface LoadedSettingsSnapshot { + system: SettingsFile; + systemDefaults: SettingsFile; + user: SettingsFile; + workspace: SettingsFile; + isTrusted: boolean; + errors: SettingsError[]; + merged: MergedSettings; +} + export class LoadedSettings { constructor( system: SettingsFile, @@ -303,6 +318,7 @@ export class LoadedSettings { : this.createEmptyWorkspace(workspace); this.errors = errors; this._merged = this.computeMergedSettings(); + this._snapshot = this.computeSnapshot(); } readonly system: SettingsFile; @@ -314,6 +330,7 @@ export class LoadedSettings { private _workspaceFile: SettingsFile; private _merged: MergedSettings; + private _snapshot: LoadedSettingsSnapshot; private _remoteAdminSettings: Partial | undefined; get merged(): MergedSettings { @@ -368,6 +385,36 @@ export class LoadedSettings { return merged; } + private computeSnapshot(): LoadedSettingsSnapshot { + const cloneSettingsFile = (file: SettingsFile): SettingsFile => ({ + path: file.path, + rawJson: file.rawJson, + settings: structuredClone(file.settings), + originalSettings: structuredClone(file.originalSettings), + }); + return { + system: cloneSettingsFile(this.system), + systemDefaults: cloneSettingsFile(this.systemDefaults), + user: cloneSettingsFile(this.user), + workspace: cloneSettingsFile(this.workspace), + isTrusted: this.isTrusted, + errors: [...this.errors], + merged: structuredClone(this._merged), + }; + } + + // Passing this along with getSnapshot to useSyncExternalStore allows for idiomatic reactivity on settings changes + // React will pass a listener fn into this subscribe fn + // that listener fn will perform an object identity check on the snapshot and trigger a React re render if the snapshot has changed + subscribe(listener: () => void): () => void { + coreEvents.on(CoreEvent.SettingsChanged, listener); + return () => coreEvents.off(CoreEvent.SettingsChanged, listener); + } + + getSnapshot(): LoadedSettingsSnapshot { + return this._snapshot; + } + forScope(scope: LoadableSettingScope): SettingsFile { switch (scope) { case SettingScope.User: @@ -409,6 +456,7 @@ export class LoadedSettings { } this._merged = this.computeMergedSettings(); + this._snapshot = this.computeSnapshot(); coreEvents.emitSettingsChanged(); } diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index dff4610b907..892cd86e4b2 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -449,6 +449,14 @@ describe('Trusted Folders', () => { false, ); }); + + it('should return true for isPathTrusted when isHeadlessMode is true', async () => { + const geminiCore = await import('@google/gemini-cli-core'); + vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true); + + const folders = loadTrustedFolders(); + expect(folders.isPathTrusted('/any-untrusted-path')).toBe(true); + }); }); describe('Trusted Folders Caching', () => { diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index 1f85684900c..761bc368d3f 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -17,6 +17,7 @@ import { homedir, isHeadlessMode, coreEvents, + type HeadlessModeOptions, } from '@google/gemini-cli-core'; import type { Settings } from './settings.js'; import stripJsonComments from 'strip-json-comments'; @@ -128,7 +129,11 @@ export class LoadedTrustedFolders { isPathTrusted( location: string, config?: Record, + headlessOptions?: HeadlessModeOptions, ): boolean | undefined { + if (isHeadlessMode(headlessOptions)) { + return true; + } const configToUse = config ?? this.user.config; // Resolve location to its realpath for canonical comparison @@ -333,6 +338,7 @@ export function isFolderTrustEnabled(settings: Settings): boolean { function getWorkspaceTrustFromLocalConfig( workspaceDir: string, trustConfig?: Record, + headlessOptions?: HeadlessModeOptions, ): TrustResult { const folders = loadTrustedFolders(); const configToUse = trustConfig ?? folders.user.config; @@ -346,7 +352,11 @@ function getWorkspaceTrustFromLocalConfig( ); } - const isTrusted = folders.isPathTrusted(workspaceDir, configToUse); + const isTrusted = folders.isPathTrusted( + workspaceDir, + configToUse, + headlessOptions, + ); return { isTrusted, source: isTrusted !== undefined ? 'file' : undefined, @@ -357,8 +367,9 @@ export function isWorkspaceTrusted( settings: Settings, workspaceDir: string = process.cwd(), trustConfig?: Record, + headlessOptions?: HeadlessModeOptions, ): TrustResult { - if (isHeadlessMode()) { + if (isHeadlessMode(headlessOptions)) { return { isTrusted: true, source: undefined }; } @@ -372,5 +383,9 @@ export function isWorkspaceTrusted( } // Fall back to the local user configuration - return getWorkspaceTrustFromLocalConfig(workspaceDir, trustConfig); + return getWorkspaceTrustFromLocalConfig( + workspaceDir, + trustConfig, + headlessOptions, + ); } diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 41f9978d7c1..2e55c9b25d1 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -238,18 +238,15 @@ vi.mock('./validateNonInterActiveAuth.js', () => ({ })); describe('gemini.tsx main function', () => { - let originalEnvGeminiSandbox: string | undefined; - let originalEnvSandbox: string | undefined; let originalIsTTY: boolean | undefined; let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] = []; beforeEach(() => { // Store and clear sandbox-related env variables to ensure a consistent test environment - originalEnvGeminiSandbox = process.env['GEMINI_SANDBOX']; - originalEnvSandbox = process.env['SANDBOX']; - delete process.env['GEMINI_SANDBOX']; - delete process.env['SANDBOX']; + vi.stubEnv('GEMINI_SANDBOX', ''); + vi.stubEnv('SANDBOX', ''); + vi.stubEnv('SHPOOL_SESSION_NAME', ''); initialUnhandledRejectionListeners = process.listeners('unhandledRejection'); @@ -260,18 +257,6 @@ describe('gemini.tsx main function', () => { }); afterEach(() => { - // Restore original env variables - if (originalEnvGeminiSandbox !== undefined) { - process.env['GEMINI_SANDBOX'] = originalEnvGeminiSandbox; - } else { - delete process.env['GEMINI_SANDBOX']; - } - if (originalEnvSandbox !== undefined) { - process.env['SANDBOX'] = originalEnvSandbox; - } else { - delete process.env['SANDBOX']; - } - const currentListeners = process.listeners('unhandledRejection'); currentListeners.forEach((listener) => { if (!initialUnhandledRejectionListeners.includes(listener)) { @@ -282,6 +267,7 @@ describe('gemini.tsx main function', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (process.stdin as any).isTTY = originalIsTTY; + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -1209,7 +1195,12 @@ describe('startInteractiveUI', () => { registerTelemetryConfig: vi.fn(), })); + beforeEach(() => { + vi.stubEnv('SHPOOL_SESSION_NAME', ''); + }); + afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -1308,7 +1299,7 @@ describe('startInteractiveUI', () => { // Verify all startup tasks were called expect(getVersion).toHaveBeenCalledTimes(1); - expect(registerCleanup).toHaveBeenCalledTimes(3); + expect(registerCleanup).toHaveBeenCalledTimes(4); // Verify cleanup handler is registered with unmount function const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0]; diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 68ce4c99b64..e138cfe03a0 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -57,8 +57,8 @@ import { writeToStderr, disableMouseEvents, enableMouseEvents, - enterAlternateScreen, disableLineWrapping, + enableLineWrapping, shouldEnterAlternateScreen, startupProfiler, ExitCodes, @@ -89,6 +89,7 @@ import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; import { VimModeProvider } from './ui/contexts/VimModeContext.js'; import { KeypressProvider } from './ui/contexts/KeypressContext.js'; import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; +import { useTerminalSize } from './ui/hooks/useTerminalSize.js'; import { relaunchAppInChildProcess, relaunchOnExitCode, @@ -214,9 +215,13 @@ export async function startInteractiveUI( const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio(); + const isShpool = !!process.env['SHPOOL_SESSION_NAME']; + // Create wrapper component to use hooks inside render const AppWrapper = () => { useKittyKeyboardProtocol(); + const { columns, rows } = useTerminalSize(); + return ( setTimeout(resolve, 100)); + } + const instance = render( process.env['DEBUG'] ? ( @@ -273,10 +290,19 @@ export async function startInteractiveUI( patchConsole: false, alternateBuffer: useAlternateBuffer, incrementalRendering: - settings.merged.ui.incrementalRendering !== false && useAlternateBuffer, + settings.merged.ui.incrementalRendering !== false && + useAlternateBuffer && + !isShpool, }, ); + if (useAlternateBuffer) { + disableLineWrapping(); + registerCleanup(() => { + enableLineWrapping(); + }); + } + checkForUpdates(settings) .then((info) => { handleAutoUpdate(info, settings, config.getProjectRoot()); @@ -335,6 +361,26 @@ export async function main() { const argv = await parseArguments(settings.merged); parseArgsHandle?.end(); + if ( + (argv.allowedTools && argv.allowedTools.length > 0) || + (settings.merged.tools?.allowed && settings.merged.tools.allowed.length > 0) + ) { + coreEvents.emitFeedback( + 'warning', + 'Warning: --allowed-tools cli argument and tools.allowed in settings.json are deprecated and will be removed in 1.0: Migrate to Policy Engine: https://geminicli.com/docs/core/policy-engine/', + ); + } + + if ( + settings.merged.tools?.exclude && + settings.merged.tools.exclude.length > 0 + ) { + coreEvents.emitFeedback( + 'warning', + 'Warning: tools.exclude in settings.json is deprecated and will be removed in 1.0. Migrate to Policy Engine: https://geminicli.com/docs/core/policy-engine/', + ); + } + if (argv.startupMessages) { argv.startupMessages.forEach((msg) => { coreEvents.emitFeedback('info', msg); @@ -590,26 +636,13 @@ export async function main() { // input showing up in the output. process.stdin.setRawMode(true); - if ( - shouldEnterAlternateScreen( - isAlternateBufferEnabled(settings), - config.getScreenReader(), - ) - ) { - enterAlternateScreen(); - disableLineWrapping(); - - // Ink will cleanup so there is no need for us to manually cleanup. - } - // This cleanup isn't strictly needed but may help in certain situations. - const restoreRawMode = () => { + process.on('SIGTERM', () => { process.stdin.setRawMode(wasRaw); - }; - process.off('SIGTERM', restoreRawMode); - process.on('SIGTERM', restoreRawMode); - process.off('SIGINT', restoreRawMode); - process.on('SIGINT', restoreRawMode); + }); + process.on('SIGINT', () => { + process.stdin.setRawMode(wasRaw); + }); } await setupTerminalAndTheme(config, settings); diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index ac2176c0e35..0a02e018895 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -18,6 +18,7 @@ export const createMockConfig = (overrides: Partial = {}): Config => getSandbox: vi.fn(() => undefined), getQuestion: vi.fn(() => ''), isInteractive: vi.fn(() => false), + isInitialized: vi.fn(() => true), setTerminalBackground: vi.fn(), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 6a19d801844..7d817f44f51 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -220,10 +220,6 @@ describe('App', () => { } as UIState; const configWithExperiment = makeFakeConfig(); - vi.spyOn( - configWithExperiment, - 'isEventDrivenSchedulerEnabled', - ).mockReturnValue(true); vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true); vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index b5b512434ed..0c333176e0d 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -20,7 +20,7 @@ import { cleanup } from 'ink-testing-library'; import { act, useContext, type ReactElement } from 'react'; import { AppContainer } from './AppContainer.js'; import { SettingsContext } from './contexts/SettingsContext.js'; -import { type TrackedToolCall } from './hooks/useReactToolScheduler.js'; +import { type TrackedToolCall } from './hooks/useToolScheduler.js'; import { type Config, makeFakeConfig, @@ -84,7 +84,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); import ansiEscapes from 'ansi-escapes'; -import { type LoadedSettings, mergeSettings } from '../config/settings.js'; +import { mergeSettings, type LoadedSettings } from '../config/settings.js'; import type { InitializationResult } from '../core/initializer.js'; import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; @@ -92,6 +92,7 @@ import { UIActionsContext, type UIActions, } from './contexts/UIActionsContext.js'; +import { KeypressProvider } from './contexts/KeypressContext.js'; // Mock useStdout to capture terminal title writes vi.mock('ink', async (importOriginal) => { @@ -133,7 +134,6 @@ vi.mock('./hooks/useGeminiStream.js'); vi.mock('./hooks/vim.js'); vi.mock('./hooks/useFocus.js'); vi.mock('./hooks/useBracketedPaste.js'); -vi.mock('./hooks/useKeypress.js'); vi.mock('./hooks/useLoadingIndicator.js'); vi.mock('./hooks/useFolderTrust.js'); vi.mock('./hooks/useIdeTrustListener.js'); @@ -197,7 +197,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; -import { useKeypress, type Key } from './hooks/useKeypress.js'; +import { useKeypress } from './hooks/useKeypress.js'; import { measureElement } from 'ink'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { @@ -232,13 +232,15 @@ describe('AppContainer State Management', () => { resumedSessionData?: ResumedSessionData; } = {}) => ( - + + + ); @@ -268,7 +270,6 @@ describe('AppContainer State Management', () => { const mockedUseTextBuffer = useTextBuffer as Mock; const mockedUseLogger = useLogger as Mock; const mockedUseLoadingIndicator = useLoadingIndicator as Mock; - const mockedUseKeypress = useKeypress as Mock; const mockedUseInputHistoryStore = useInputHistoryStore as Mock; const mockedUseHookDisplayState = useHookDisplayState as Mock; const mockedUseTerminalTheme = useTerminalTheme as Mock; @@ -1770,47 +1771,36 @@ describe('AppContainer State Management', () => { }); describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => { - let handleGlobalKeypress: (key: Key) => boolean; let mockHandleSlashCommand: Mock; let mockCancelOngoingRequest: Mock; let rerender: () => void; let unmount: () => void; + let stdin: ReturnType['stdin']; // Helper function to reduce boilerplate in tests const setupKeypressTest = async () => { const renderResult = renderAppContainer(); + stdin = renderResult.stdin; await act(async () => { vi.advanceTimersByTime(0); }); - rerender = () => renderResult.rerender(getAppContainer()); + rerender = () => { + renderResult.rerender(getAppContainer()); + }; unmount = renderResult.unmount; }; - const pressKey = (key: Partial, times = 1) => { + const pressKey = (sequence: string, times = 1) => { for (let i = 0; i < times; i++) { act(() => { - handleGlobalKeypress({ - name: 'c', - shift: false, - alt: false, - ctrl: false, - cmd: false, - ...key, - } as Key); + stdin.write(sequence); }); rerender(); } }; beforeEach(() => { - // Capture the keypress handler from the AppContainer - mockedUseKeypress.mockImplementation( - (callback: (key: Key) => boolean) => { - handleGlobalKeypress = callback; - }, - ); - // Mock slash command handler mockHandleSlashCommand = vi.fn(); mockedUseSlashCommandProcessor.mockReturnValue({ @@ -1855,7 +1845,7 @@ describe('AppContainer State Management', () => { }); await setupKeypressTest(); - pressKey({ name: 'c', ctrl: true }); + pressKey('\x03'); // Ctrl+C expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(1); expect(mockHandleSlashCommand).not.toHaveBeenCalled(); @@ -1865,7 +1855,7 @@ describe('AppContainer State Management', () => { it('should quit on second press', async () => { await setupKeypressTest(); - pressKey({ name: 'c', ctrl: true }, 2); + pressKey('\x03', 2); // Ctrl+C expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(2); expect(mockHandleSlashCommand).toHaveBeenCalledWith( @@ -1880,7 +1870,7 @@ describe('AppContainer State Management', () => { it('should reset press count after a timeout', async () => { await setupKeypressTest(); - pressKey({ name: 'c', ctrl: true }); + pressKey('\x03'); // Ctrl+C expect(mockHandleSlashCommand).not.toHaveBeenCalled(); // Advance timer past the reset threshold @@ -1888,7 +1878,7 @@ describe('AppContainer State Management', () => { vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1); }); - pressKey({ name: 'c', ctrl: true }); + pressKey('\x03'); // Ctrl+C expect(mockHandleSlashCommand).not.toHaveBeenCalled(); unmount(); }); @@ -1898,7 +1888,7 @@ describe('AppContainer State Management', () => { it('should quit on second press if buffer is empty', async () => { await setupKeypressTest(); - pressKey({ name: 'd', ctrl: true }, 2); + pressKey('\x04', 2); // Ctrl+D expect(mockHandleSlashCommand).toHaveBeenCalledWith( '/quit', @@ -1909,7 +1899,7 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should NOT quit if buffer is not empty (bubbles from InputPrompt)', async () => { + it('should NOT quit if buffer is not empty', async () => { mockedUseTextBuffer.mockReturnValue({ text: 'some text', setText: vi.fn(), @@ -1919,30 +1909,12 @@ describe('AppContainer State Management', () => { }); await setupKeypressTest(); - // Capture return value - let result = true; - const originalPressKey = (key: Partial) => { - act(() => { - result = handleGlobalKeypress({ - name: 'd', - shift: false, - alt: false, - ctrl: true, - cmd: false, - ...key, - } as Key); - }); - rerender(); - }; - - originalPressKey({ name: 'd', ctrl: true }); + pressKey('\x04'); // Ctrl+D - // AppContainer's handler should return true if it reaches it - expect(result).toBe(true); - // But it should only be called once, so count is 1, not quitting yet. + // Should only be called once, so count is 1, not quitting yet. expect(mockHandleSlashCommand).not.toHaveBeenCalled(); - originalPressKey({ name: 'd', ctrl: true }); + pressKey('\x04'); // Ctrl+D // Now count is 2, it should quit. expect(mockHandleSlashCommand).toHaveBeenCalledWith( '/quit', @@ -1956,7 +1928,7 @@ describe('AppContainer State Management', () => { it('should reset press count after a timeout', async () => { await setupKeypressTest(); - pressKey({ name: 'd', ctrl: true }); + pressKey('\x04'); // Ctrl+D expect(mockHandleSlashCommand).not.toHaveBeenCalled(); // Advance timer past the reset threshold @@ -1964,7 +1936,7 @@ describe('AppContainer State Management', () => { vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1); }); - pressKey({ name: 'd', ctrl: true }); + pressKey('\x04'); // Ctrl+D expect(mockHandleSlashCommand).not.toHaveBeenCalled(); unmount(); }); @@ -1982,7 +1954,7 @@ describe('AppContainer State Management', () => { it('should focus shell input on Tab', async () => { await setupKeypressTest(); - pressKey({ name: 'tab', shift: false }); + pressKey('\t'); expect(capturedUIState.embeddedShellFocused).toBe(true); unmount(); @@ -1992,11 +1964,11 @@ describe('AppContainer State Management', () => { await setupKeypressTest(); // Focus first - pressKey({ name: 'tab', shift: false }); + pressKey('\t'); expect(capturedUIState.embeddedShellFocused).toBe(true); // Unfocus via Shift+Tab - pressKey({ name: 'tab', shift: true }); + pressKey('\x1b[Z'); expect(capturedUIState.embeddedShellFocused).toBe(false); unmount(); }); @@ -2015,13 +1987,7 @@ describe('AppContainer State Management', () => { // Focus it act(() => { - handleGlobalKeypress({ - name: 'tab', - shift: false, - alt: false, - ctrl: false, - cmd: false, - } as Key); + renderResult.stdin.write('\t'); }); expect(capturedUIState.embeddedShellFocused).toBe(true); @@ -2056,7 +2022,7 @@ describe('AppContainer State Management', () => { expect(capturedUIState.embeddedShellFocused).toBe(false); // Press Tab - pressKey({ name: 'tab', shift: false }); + pressKey('\t'); // Should be focused expect(capturedUIState.embeddedShellFocused).toBe(true); @@ -2084,7 +2050,7 @@ describe('AppContainer State Management', () => { expect(capturedUIState.embeddedShellFocused).toBe(false); // Press Ctrl+B - pressKey({ name: 'b', ctrl: true }); + pressKey('\x02'); // Should have toggled (closed) the shell expect(mockToggleBackgroundShell).toHaveBeenCalled(); @@ -2113,7 +2079,7 @@ describe('AppContainer State Management', () => { }); // Press Ctrl+B - pressKey({ name: 'b', ctrl: true }); + pressKey('\x02'); // Should have toggled (shown) the shell expect(mockToggleBackgroundShell).toHaveBeenCalled(); @@ -2126,11 +2092,14 @@ describe('AppContainer State Management', () => { }); describe('Copy Mode (CTRL+S)', () => { - let handleGlobalKeypress: (key: Key) => boolean; let rerender: () => void; let unmount: () => void; + let stdin: ReturnType['stdin']; - const setupCopyModeTest = async (isAlternateMode = false) => { + const setupCopyModeTest = async ( + isAlternateMode = false, + childHandler?: Mock, + ) => { // Update settings for this test run const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const testSettings = { @@ -2144,23 +2113,39 @@ describe('AppContainer State Management', () => { }, } as unknown as LoadedSettings; - const renderResult = renderAppContainer({ settings: testSettings }); + function TestChild() { + useKeypress(childHandler || (() => {}), { + isActive: !!childHandler, + priority: true, + }); + return null; + } + + const getTree = (settings: LoadedSettings) => ( + + + + + + + ); + + const renderResult = render(getTree(testSettings)); + stdin = renderResult.stdin; await act(async () => { vi.advanceTimersByTime(0); }); - rerender = () => - renderResult.rerender(getAppContainer({ settings: testSettings })); + rerender = () => renderResult.rerender(getTree(testSettings)); unmount = renderResult.unmount; }; beforeEach(() => { mocks.mockStdout.write.mockClear(); - mockedUseKeypress.mockImplementation( - (callback: (key: Key) => boolean) => { - handleGlobalKeypress = callback; - }, - ); vi.useFakeTimers(); }); @@ -2186,15 +2171,7 @@ describe('AppContainer State Management', () => { mocks.mockStdout.write.mockClear(); // Clear initial enable call act(() => { - handleGlobalKeypress({ - name: 's', - shift: false, - alt: false, - ctrl: true, - cmd: false, - insertable: false, - sequence: '\x13', - }); + stdin.write('\x13'); // Ctrl+S }); rerender(); @@ -2213,30 +2190,14 @@ describe('AppContainer State Management', () => { // Turn it on (disable mouse) act(() => { - handleGlobalKeypress({ - name: 's', - shift: false, - alt: false, - ctrl: true, - cmd: false, - insertable: false, - sequence: '\x13', - }); + stdin.write('\x13'); // Ctrl+S }); rerender(); expect(disableMouseEvents).toHaveBeenCalled(); // Turn it off (enable mouse) act(() => { - handleGlobalKeypress({ - name: 'any', // Any key should exit copy mode - shift: false, - alt: false, - ctrl: false, - cmd: false, - insertable: true, - sequence: 'a', - }); + stdin.write('a'); // Any key should exit copy mode }); rerender(); @@ -2249,15 +2210,7 @@ describe('AppContainer State Management', () => { // Enter copy mode act(() => { - handleGlobalKeypress({ - name: 's', - shift: false, - alt: false, - ctrl: true, - cmd: false, - insertable: false, - sequence: '\x13', - }); + stdin.write('\x13'); // Ctrl+S }); rerender(); @@ -2265,15 +2218,7 @@ describe('AppContainer State Management', () => { // Press any other key act(() => { - handleGlobalKeypress({ - name: 'a', - shift: false, - alt: false, - ctrl: false, - cmd: false, - insertable: true, - sequence: 'a', - }); + stdin.write('a'); }); rerender(); @@ -2281,6 +2226,37 @@ describe('AppContainer State Management', () => { expect(enableMouseEvents).toHaveBeenCalled(); unmount(); }); + + it('should have higher priority than other priority listeners when enabled', async () => { + // 1. Initial state with a child component's priority listener (already subscribed) + // It should NOT handle Ctrl+S so we can enter copy mode. + const childHandler = vi.fn().mockReturnValue(false); + await setupCopyModeTest(true, childHandler); + + // 2. Enter copy mode + act(() => { + stdin.write('\x13'); // Ctrl+S + }); + rerender(); + + // 3. Verify we are in copy mode + expect(disableMouseEvents).toHaveBeenCalled(); + + // 4. Press any key + childHandler.mockClear(); + // Now childHandler should return true for other keys, simulating a greedy listener + childHandler.mockReturnValue(true); + + act(() => { + stdin.write('a'); + }); + rerender(); + + // 5. Verify that the exit handler took priority and childHandler was NOT called + expect(childHandler).not.toHaveBeenCalled(); + expect(enableMouseEvents).toHaveBeenCalled(); + unmount(); + }); } }); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 7d863a638fe..72fdb0ce486 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -101,6 +101,7 @@ import { type LoadableSettingScope, SettingScope } from '../config/settings.js'; import { type InitializationResult } from '../core/initializer.js'; import { useFocus } from './hooks/useFocus.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; +import { KeypressPriority } from './contexts/KeypressContext.js'; import { keyMatchers, Command } from './keyMatchers.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; @@ -363,7 +364,9 @@ export const AppContainer = (props: AppContainerProps) => { (async () => { // Note: the program will not work if this fails so let errors be // handled by the global catch. - await config.initialize(); + if (!config.isInitialized()) { + await config.initialize(); + } setConfigInitialized(true); startupProfiler.flush(config); @@ -1481,13 +1484,6 @@ Logging in with Google... Restarting Gemini CLI to continue. const handleGlobalKeypress = useCallback( (key: Key): boolean => { - if (copyModeEnabled) { - setCopyModeEnabled(false); - enableMouseEvents(); - // We don't want to process any other keys if we're in copy mode. - return true; - } - // Debug log keystrokes if enabled if (settings.merged.general.debugKeystrokeLogging) { debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); @@ -1525,6 +1521,7 @@ Logging in with Google... Restarting Gemini CLI to continue. ); await toggleDevToolsPanel( config, + showErrorDetails, () => setShowErrorDetails((prev) => !prev), () => setShowErrorDetails(true), ); @@ -1654,7 +1651,6 @@ Logging in with Google... Restarting Gemini CLI to continue. settings.merged.general.debugKeystrokeLogging, refreshStatic, setCopyModeEnabled, - copyModeEnabled, isAlternateBuffer, backgroundCurrentShell, toggleBackgroundShell, @@ -1665,11 +1661,26 @@ Logging in with Google... Restarting Gemini CLI to continue. tabFocusTimeoutRef, showTransientMessage, settings.merged.general.devtools, + showErrorDetails, ], ); useKeypress(handleGlobalKeypress, { isActive: true, priority: true }); + useKeypress( + () => { + setCopyModeEnabled(false); + enableMouseEvents(); + return true; + }, + { + isActive: copyModeEnabled, + // We need to receive keypresses first so they do not bubble to other + // handlers. + priority: KeypressPriority.Critical, + }, + ); + useEffect(() => { // Respect hideWindowTitle settings if (settings.merged.ui.hideWindowTitle) return; diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx index fec35d46c39..8e0ede2e098 100644 --- a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx +++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx @@ -12,18 +12,15 @@ import { QuittingDisplay } from './QuittingDisplay.js'; import { useAppContext } from '../contexts/AppContext.js'; import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js'; import { useConfirmingTool } from '../hooks/useConfirmingTool.js'; -import { useConfig } from '../contexts/ConfigContext.js'; import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js'; import { theme } from '../semantic-colors.js'; export const AlternateBufferQuittingDisplay = () => { const { version } = useAppContext(); const uiState = useUIState(); - const config = useConfig(); const confirmingTool = useConfirmingTool(); - const showPromptedTool = - config.isEventDrivenSchedulerEnabled() && confirmingTool !== null; + const showPromptedTool = confirmingTool !== null; // We render the entire chat history and header here to ensure that the // conversation history is visible to the user after the app quits and the @@ -56,7 +53,6 @@ export const AlternateBufferQuittingDisplay = () => { terminalWidth={uiState.mainAreaWidth} item={{ ...item, id: 0 }} isPending={true} - isFocused={false} activeShellPtyId={uiState.activePtyId} embeddedShellFocused={uiState.embeddedShellFocused} /> diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 41340c1b089..f41ee20895d 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -43,7 +43,6 @@ interface HistoryItemDisplayProps { availableTerminalHeight?: number; terminalWidth: number; isPending: boolean; - isFocused?: boolean; commands?: readonly SlashCommand[]; activeShellPtyId?: number | null; embeddedShellFocused?: boolean; @@ -56,7 +55,6 @@ export const HistoryItemDisplay: React.FC = ({ terminalWidth, isPending, commands, - isFocused = true, activeShellPtyId, embeddedShellFocused, availableTerminalHeightGemini, @@ -179,7 +177,6 @@ export const HistoryItemDisplay: React.FC = ({ groupId={itemForDisplay.id} availableTerminalHeight={availableTerminalHeight} terminalWidth={terminalWidth} - isFocused={isFocused} activeShellPtyId={activeShellPtyId} embeddedShellFocused={embeddedShellFocused} borderTop={itemForDisplay.borderTop} diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 32c70e8cadc..586553a1f29 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -19,7 +19,6 @@ import { useMemo, memo, useCallback, useEffect, useRef } from 'react'; import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js'; import { useConfirmingTool } from '../hooks/useConfirmingTool.js'; import { ToolConfirmationQueue } from './ToolConfirmationQueue.js'; -import { useConfig } from '../contexts/ConfigContext.js'; const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay); const MemoizedAppHeader = memo(AppHeader); @@ -31,12 +30,10 @@ const MemoizedAppHeader = memo(AppHeader); export const MainContent = () => { const { version } = useAppContext(); const uiState = useUIState(); - const config = useConfig(); const isAlternateBuffer = useAlternateBuffer(); const confirmingTool = useConfirmingTool(); - const showConfirmationQueue = - config.isEventDrivenSchedulerEnabled() && confirmingTool !== null; + const showConfirmationQueue = confirmingTool !== null; const scrollableListRef = useRef>(null); @@ -89,7 +86,6 @@ export const MainContent = () => { terminalWidth={mainAreaWidth} item={{ ...item, id: 0 }} isPending={true} - isFocused={!uiState.isEditorDialogOpen} activeShellPtyId={uiState.activePtyId} embeddedShellFocused={uiState.embeddedShellFocused} /> @@ -105,7 +101,6 @@ export const MainContent = () => { isAlternateBuffer, availableTerminalHeight, mainAreaWidth, - uiState.isEditorDialogOpen, uiState.activePtyId, uiState.embeddedShellFocused, showConfirmationQueue, diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessageOverflow.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessageOverflow.test.tsx deleted file mode 100644 index b59b6c5adf8..00000000000 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessageOverflow.test.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi } from 'vitest'; -import { ToolGroupMessage } from './ToolGroupMessage.js'; -import type { - ToolCallConfirmationDetails, - Config, -} from '@google/gemini-cli-core'; -import { renderWithProviders } from '../../../test-utils/render.js'; -import { useToolActions } from '../../contexts/ToolActionsContext.js'; -import { - StreamingState, - ToolCallStatus, - type IndividualToolCallDisplay, -} from '../../types.js'; -import { OverflowProvider } from '../../contexts/OverflowContext.js'; -import { waitFor } from '../../../test-utils/async.js'; - -vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => { - const actual = - await importOriginal< - typeof import('../../contexts/ToolActionsContext.js') - >(); - return { - ...actual, - useToolActions: vi.fn(), - }; -}); - -describe('ToolConfirmationMessage Overflow', () => { - const mockConfirm = vi.fn(); - vi.mocked(useToolActions).mockReturnValue({ - confirm: mockConfirm, - cancel: vi.fn(), - isDiffingEnabled: false, - }); - - const mockConfig = { - isTrustedFolder: () => true, - getIdeMode: () => false, - getMessageBus: () => ({ - subscribe: vi.fn(), - unsubscribe: vi.fn(), - publish: vi.fn(), - }), - isEventDrivenSchedulerEnabled: () => false, - getTheme: () => ({ - status: { warning: 'yellow' }, - text: { primary: 'white', secondary: 'gray', link: 'blue' }, - border: { default: 'gray' }, - ui: { symbol: 'cyan' }, - }), - } as unknown as Config; - - it('should display "press ctrl-o" hint when content overflows in ToolGroupMessage', async () => { - // Large diff that will definitely overflow - const diffLines = ['--- a/test.txt', '+++ b/test.txt', '@@ -1,20 +1,20 @@']; - for (let i = 0; i < 50; i++) { - diffLines.push(`+ line ${i + 1}`); - } - const fileDiff = diffLines.join('\n'); - - const confirmationDetails: ToolCallConfirmationDetails = { - type: 'edit', - title: 'Confirm Edit', - fileName: 'test.txt', - filePath: '/test.txt', - fileDiff, - originalContent: '', - newContent: 'lots of lines', - onConfirm: vi.fn(), - }; - - const toolCalls: IndividualToolCallDisplay[] = [ - { - callId: 'test-call-id', - name: 'test-tool', - description: 'a test tool', - status: ToolCallStatus.Confirming, - confirmationDetails, - resultDisplay: undefined, - }, - ]; - - const { lastFrame } = renderWithProviders( - - - , - { - config: mockConfig, - uiState: { - streamingState: StreamingState.WaitingForConfirmation, - constrainHeight: true, - }, - }, - ); - - // ResizeObserver might take a tick - await waitFor(() => - expect(lastFrame()).toContain('Press ctrl-o to show more lines'), - ); - - const frame = lastFrame(); - expect(frame).toBeDefined(); - if (frame) { - expect(frame).toContain('Press ctrl-o to show more lines'); - // Ensure it's AFTER the bottom border - const linesOfOutput = frame.split('\n'); - const bottomBorderIndex = linesOfOutput.findLastIndex((l) => - l.includes('╰─'), - ); - const hintIndex = linesOfOutput.findIndex((l) => - l.includes('Press ctrl-o to show more lines'), - ); - expect(hintIndex).toBeGreaterThan(bottomBorderIndex); - expect(frame).toMatchSnapshot(); - } - }); -}); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index 5368684ea28..d2d3cd277a1 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -5,7 +5,6 @@ */ import { renderWithProviders } from '../../../test-utils/render.js'; -import { createMockSettings } from '../../../test-utils/settings.js'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { ToolGroupMessage } from './ToolGroupMessage.js'; import type { IndividualToolCallDisplay } from '../../types.js'; @@ -35,7 +34,6 @@ describe('', () => { const baseProps = { groupId: 1, terminalWidth: 80, - isFocused: true, }; const baseMockConfig = makeFakeConfig({ @@ -45,7 +43,6 @@ describe('', () => { folderTrust: false, ideMode: false, enableInteractiveShell: true, - enableEventDrivenScheduler: true, }); describe('Golden Snapshots', () => { @@ -64,89 +61,52 @@ describe('', () => { unmount(); }); - it('renders multiple tool calls with different statuses', () => { + it('hides confirming tools (standard behavior)', () => { const toolCalls = [ createToolCall({ - callId: 'tool-1', - name: 'successful-tool', - description: 'This tool succeeded', - status: ToolCallStatus.Success, - }), - createToolCall({ - callId: 'tool-2', - name: 'pending-tool', - description: 'This tool is pending', - status: ToolCallStatus.Pending, - }), - createToolCall({ - callId: 'tool-3', - name: 'error-tool', - description: 'This tool failed', - status: ToolCallStatus.Error, - }), - ]; - const mockConfig = makeFakeConfig({ - model: 'gemini-pro', - targetDir: os.tmpdir(), - enableEventDrivenScheduler: false, - }); - - const { lastFrame, unmount } = renderWithProviders( - , - { - config: mockConfig, - uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], - }, - }, - ); - expect(lastFrame()).toMatchSnapshot(); - unmount(); - }); - - it('renders tool call awaiting confirmation', () => { - const toolCalls = [ - createToolCall({ - callId: 'tool-confirm', - name: 'confirmation-tool', - description: 'This tool needs confirmation', + callId: 'confirm-tool', status: ToolCallStatus.Confirming, confirmationDetails: { type: 'info', - title: 'Confirm Tool Execution', - prompt: 'Are you sure you want to proceed?', + title: 'Confirm tool', + prompt: 'Do you want to proceed?', onConfirm: vi.fn(), }, }), ]; - const mockConfig = makeFakeConfig({ - model: 'gemini-pro', - targetDir: os.tmpdir(), - enableEventDrivenScheduler: false, - }); const { lastFrame, unmount } = renderWithProviders( , - { - config: mockConfig, - uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], - }, - }, + { config: baseMockConfig }, ); - expect(lastFrame()).toMatchSnapshot(); + + // Should render nothing because all tools in the group are confirming + expect(lastFrame()).toBe(''); unmount(); }); - it('renders shell command with yellow border', () => { + it('renders multiple tool calls with different statuses (only visible ones)', () => { const toolCalls = [ createToolCall({ - callId: 'shell-1', - name: 'run_shell_command', - description: 'Execute shell command', + callId: 'tool-1', + name: 'successful-tool', + description: 'This tool succeeded', status: ToolCallStatus.Success, }), + createToolCall({ + callId: 'tool-2', + name: 'pending-tool', + description: 'This tool is pending', + status: ToolCallStatus.Pending, + }), + createToolCall({ + callId: 'tool-3', + name: 'error-tool', + description: 'This tool failed', + status: ToolCallStatus.Error, + }), ]; + const { lastFrame, unmount } = renderWithProviders( , { @@ -156,7 +116,12 @@ describe('', () => { }, }, ); - expect(lastFrame()).toMatchSnapshot(); + // pending-tool should be hidden + const output = lastFrame(); + expect(output).toContain('successful-tool'); + expect(output).not.toContain('pending-tool'); + expect(output).toContain('error-tool'); + expect(output).toMatchSnapshot(); unmount(); }); @@ -181,22 +146,22 @@ describe('', () => { status: ToolCallStatus.Pending, }), ]; - const mockConfig = makeFakeConfig({ - model: 'gemini-pro', - targetDir: os.tmpdir(), - enableEventDrivenScheduler: false, - }); const { lastFrame, unmount } = renderWithProviders( , { - config: mockConfig, + config: baseMockConfig, uiState: { pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], }, }, ); - expect(lastFrame()).toMatchSnapshot(); + // write_file (Pending) should be hidden + const output = lastFrame(); + expect(output).toContain('read_file'); + expect(output).toContain('run_shell_command'); + expect(output).not.toContain('write_file'); + expect(output).toMatchSnapshot(); unmount(); }); @@ -233,25 +198,6 @@ describe('', () => { unmount(); }); - it('renders when not focused', () => { - const toolCalls = [createToolCall()]; - const { lastFrame, unmount } = renderWithProviders( - , - { - config: baseMockConfig, - uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], - }, - }, - ); - expect(lastFrame()).toMatchSnapshot(); - unmount(); - }); - it('renders with narrow terminal width', () => { const toolCalls = [ createToolCall({ @@ -384,28 +330,6 @@ describe('', () => { }); describe('Border Color Logic', () => { - it('uses yellow border when tools are pending', () => { - const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })]; - const mockConfig = makeFakeConfig({ - model: 'gemini-pro', - targetDir: os.tmpdir(), - enableEventDrivenScheduler: false, - }); - - const { lastFrame, unmount } = renderWithProviders( - , - { - config: mockConfig, - uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], - }, - }, - ); - // The snapshot will capture the visual appearance including border color - expect(lastFrame()).toMatchSnapshot(); - unmount(); - }); - it('uses yellow border for shell commands even when successful', () => { const toolCalls = [ createToolCall({ @@ -483,210 +407,6 @@ describe('', () => { }); }); - describe('Confirmation Handling', () => { - it('shows confirmation dialog for first confirming tool only', () => { - const toolCalls = [ - createToolCall({ - callId: 'tool-1', - name: 'first-confirm', - status: ToolCallStatus.Confirming, - confirmationDetails: { - type: 'info', - title: 'Confirm First Tool', - prompt: 'Confirm first tool', - onConfirm: vi.fn(), - }, - }), - createToolCall({ - callId: 'tool-2', - name: 'second-confirm', - status: ToolCallStatus.Confirming, - confirmationDetails: { - type: 'info', - title: 'Confirm Second Tool', - prompt: 'Confirm second tool', - onConfirm: vi.fn(), - }, - }), - ]; - const mockConfig = makeFakeConfig({ - model: 'gemini-pro', - targetDir: os.tmpdir(), - enableEventDrivenScheduler: false, - }); - - const { lastFrame, unmount } = renderWithProviders( - , - { - config: mockConfig, - uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], - }, - }, - ); - // Should only show confirmation for the first tool - expect(lastFrame()).toMatchSnapshot(); - unmount(); - }); - - it('renders confirmation with permanent approval enabled', () => { - const toolCalls = [ - createToolCall({ - callId: 'tool-1', - name: 'confirm-tool', - status: ToolCallStatus.Confirming, - confirmationDetails: { - type: 'info', - title: 'Confirm Tool', - prompt: 'Do you want to proceed?', - onConfirm: vi.fn(), - }, - }), - ]; - const settings = createMockSettings({ - security: { enablePermanentToolApproval: true }, - }); - const mockConfig = makeFakeConfig({ - model: 'gemini-pro', - targetDir: os.tmpdir(), - enableEventDrivenScheduler: false, - }); - - const { lastFrame, unmount } = renderWithProviders( - , - { - settings, - config: mockConfig, - uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], - }, - }, - ); - expect(lastFrame()).toContain('Allow for all future sessions'); - expect(lastFrame()).toMatchSnapshot(); - unmount(); - }); - - it('renders confirmation with permanent approval disabled', () => { - const toolCalls = [ - createToolCall({ - callId: 'confirm-tool', - name: 'confirm-tool', - status: ToolCallStatus.Confirming, - confirmationDetails: { - type: 'info', - title: 'Confirm tool', - prompt: 'Do you want to proceed?', - onConfirm: vi.fn(), - }, - }), - ]; - - const mockConfig = makeFakeConfig({ - model: 'gemini-pro', - targetDir: os.tmpdir(), - enableEventDrivenScheduler: false, - }); - - const { lastFrame, unmount } = renderWithProviders( - , - { config: mockConfig }, - ); - expect(lastFrame()).not.toContain('Allow for all future sessions'); - expect(lastFrame()).toMatchSnapshot(); - unmount(); - }); - }); - - describe('Event-Driven Scheduler', () => { - it('hides confirming tools when event-driven scheduler is enabled', () => { - const toolCalls = [ - createToolCall({ - callId: 'confirm-tool', - status: ToolCallStatus.Confirming, - confirmationDetails: { - type: 'info', - title: 'Confirm tool', - prompt: 'Do you want to proceed?', - onConfirm: vi.fn(), - }, - }), - ]; - - const mockConfig = baseMockConfig; - - const { lastFrame, unmount } = renderWithProviders( - , - { config: mockConfig }, - ); - - // Should render nothing because all tools in the group are confirming - expect(lastFrame()).toBe(''); - expect(lastFrame()).toMatchSnapshot(); - unmount(); - }); - - it('shows only successful tools when mixed with confirming tools', () => { - const toolCalls = [ - createToolCall({ - callId: 'success-tool', - name: 'success-tool', - status: ToolCallStatus.Success, - }), - createToolCall({ - callId: 'confirm-tool', - name: 'confirm-tool', - status: ToolCallStatus.Confirming, - confirmationDetails: { - type: 'info', - title: 'Confirm tool', - prompt: 'Do you want to proceed?', - onConfirm: vi.fn(), - }, - }), - ]; - - const mockConfig = baseMockConfig; - - const { lastFrame, unmount } = renderWithProviders( - , - { config: mockConfig }, - ); - - const output = lastFrame(); - expect(output).toContain('success-tool'); - expect(output).not.toContain('confirm-tool'); - expect(output).not.toContain('Do you want to proceed?'); - expect(output).toMatchSnapshot(); - unmount(); - }); - - it('renders nothing when only tool is in-progress AskUser with borderBottom=false', () => { - // AskUser tools in progress are rendered by AskUserDialog, not ToolGroupMessage. - // When AskUser is the only tool and borderBottom=false (no border to close), - // the component should render nothing. - const toolCalls = [ - createToolCall({ - callId: 'ask-user-tool', - name: 'Ask User', - status: ToolCallStatus.Executing, - }), - ]; - - const { lastFrame, unmount } = renderWithProviders( - , - { config: baseMockConfig }, - ); - // AskUser tools in progress are rendered by AskUserDialog, so we expect nothing. - expect(lastFrame()).toMatchSnapshot(); - unmount(); - }); - }); - describe('Ask User Filtering', () => { it.each([ ToolCallStatus.Pending, @@ -753,5 +473,30 @@ describe('', () => { expect(lastFrame()).toMatchSnapshot(); unmount(); }); + + it('renders nothing when only tool is in-progress AskUser with borderBottom=false', () => { + // AskUser tools in progress are rendered by AskUserDialog, not ToolGroupMessage. + // When AskUser is the only tool and borderBottom=false (no border to close), + // the component should render nothing. + const toolCalls = [ + createToolCall({ + callId: 'ask-user-tool', + name: ASK_USER_DISPLAY_NAME, + status: ToolCallStatus.Executing, + }), + ]; + + const { lastFrame, unmount } = renderWithProviders( + , + { config: baseMockConfig }, + ); + // AskUser tools in progress are rendered by AskUserDialog, so we expect nothing. + expect(lastFrame()).toBe(''); + unmount(); + }); }); }); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index f9225b60e7a..07ae280558c 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -11,7 +11,6 @@ import type { IndividualToolCallDisplay } from '../../types.js'; import { ToolCallStatus } from '../../types.js'; import { ToolMessage } from './ToolMessage.js'; import { ShellToolMessage } from './ShellToolMessage.js'; -import { ToolConfirmationMessage } from './ToolConfirmationMessage.js'; import { theme } from '../../semantic-colors.js'; import { useConfig } from '../../contexts/ConfigContext.js'; import { isShellTool, isThisShellFocused } from './ToolShared.js'; @@ -24,7 +23,6 @@ interface ToolGroupMessageProps { toolCalls: IndividualToolCallDisplay[]; availableTerminalHeight?: number; terminalWidth: number; - isFocused?: boolean; activeShellPtyId?: number | null; embeddedShellFocused?: boolean; onShellInputSubmit?: (input: string) => void; @@ -43,13 +41,11 @@ const isAskUserInProgress = (t: IndividualToolCallDisplay): boolean => // Main component renders the border and maps the tools using ToolMessage const TOOL_MESSAGE_HORIZONTAL_MARGIN = 4; -const TOOL_CONFIRMATION_INTERNAL_PADDING = 4; export const ToolGroupMessage: React.FC = ({ toolCalls: allToolCalls, availableTerminalHeight, terminalWidth, - isFocused = true, activeShellPtyId, embeddedShellFocused, borderTop: borderTopOverride, @@ -64,24 +60,20 @@ export const ToolGroupMessage: React.FC = ({ const config = useConfig(); const { constrainHeight } = useUIState(); - const isEventDriven = config.isEventDrivenSchedulerEnabled(); - - // If Event-Driven Scheduler is enabled, we HIDE tools that are still in - // pre-execution states (Confirming, Pending) from the History log. - // They live in the Global Queue or wait for their turn. - const visibleToolCalls = useMemo(() => { - if (!isEventDriven) { - return toolCalls; - } - // Only show tools that are actually running or finished. - // We explicitly exclude Pending and Confirming to ensure they only - // appear in the Global Queue until they are approved and start executing. - return toolCalls.filter( - (t) => - t.status !== ToolCallStatus.Pending && - t.status !== ToolCallStatus.Confirming, - ); - }, [toolCalls, isEventDriven]); + // We HIDE tools that are still in pre-execution states (Confirming, Pending) + // from the History log. They live in the Global Queue or wait for their turn. + // Only show tools that are actually running or finished. + // We explicitly exclude Pending and Confirming to ensure they only + // appear in the Global Queue until they are approved and start executing. + const visibleToolCalls = useMemo( + () => + toolCalls.filter( + (t) => + t.status !== ToolCallStatus.Pending && + t.status !== ToolCallStatus.Confirming, + ), + [toolCalls], + ); const isEmbeddedShellFocused = visibleToolCalls.some((t) => isThisShellFocused( @@ -110,17 +102,8 @@ export const ToolGroupMessage: React.FC = ({ const staticHeight = /* border */ 2 + /* marginBottom */ 1; - // Inline confirmations are ONLY used when the Global Queue is disabled. - const toolAwaitingApproval = useMemo( - () => - isEventDriven - ? undefined - : toolCalls.find((tc) => tc.status === ToolCallStatus.Confirming), - [toolCalls, isEventDriven], - ); - - // If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools - // in event-driven mode), only render if we need to close a border from previous + // If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools), + // only render if we need to close a border from previous // tool groups. borderBottomOverride=true means we must render the closing border; // undefined or false means there's nothing to display. if (visibleToolCalls.length === 0 && borderBottomOverride !== true) { @@ -163,7 +146,6 @@ export const ToolGroupMessage: React.FC = ({ paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN} > {visibleToolCalls.map((tool, index) => { - const isConfirming = toolAwaitingApproval?.callId === tool.callId; const isFirst = index === 0; const isShellToolCall = isShellTool(tool.name); @@ -171,11 +153,7 @@ export const ToolGroupMessage: React.FC = ({ ...tool, availableTerminalHeight: availableTerminalHeightPerToolMessage, terminalWidth: contentWidth, - emphasis: isConfirming - ? ('high' as const) - : toolAwaitingApproval - ? ('low' as const) - : ('medium' as const), + emphasis: 'medium' as const, isFirst: borderTopOverride !== undefined ? borderTopOverride && isFirst @@ -213,22 +191,6 @@ export const ToolGroupMessage: React.FC = ({ paddingLeft={1} paddingRight={1} > - {tool.status === ToolCallStatus.Confirming && - isConfirming && - tool.confirmationDetails && ( - - )} {tool.outputFile && ( 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 369fa591741..3586b32c217 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 @@ -50,76 +50,6 @@ exports[` > Border Color Logic > uses yellow border for shel ╰──────────────────────────────────────────────────────────────────────────╯" `; -exports[` > Border Color Logic > uses yellow border when tools are pending 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────╮ -│ o test-tool A tool for testing │ -│ │ -│ Test result │ -╰──────────────────────────────────────────────────────────────────────────╯" -`; - -exports[` > Confirmation Handling > renders confirmation with permanent approval disabled 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────╮ -│ ? confirm-tool A tool for testing ← │ -│ │ -│ Test result │ -│ Do you want to proceed? │ -│ Do you want to proceed? │ -│ │ -│ ● 1. Allow once │ -│ 2. Allow for this session │ -│ 3. No, suggest changes (esc) │ -│ │ -╰──────────────────────────────────────────────────────────────────────────╯" -`; - -exports[` > Confirmation Handling > renders confirmation with permanent approval enabled 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────╮ -│ ? confirm-tool A tool for testing ← │ -│ │ -│ Test result │ -│ Do you want to proceed? │ -│ Do you want to proceed? │ -│ │ -│ ● 1. Allow once │ -│ 2. Allow for this session │ -│ 3. Allow for all future sessions │ -│ 4. No, suggest changes (esc) │ -│ │ -╰──────────────────────────────────────────────────────────────────────────╯" -`; - -exports[` > Confirmation Handling > shows confirmation dialog for first confirming tool only 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────╮ -│ ? first-confirm A tool for testing ← │ -│ │ -│ Test result │ -│ Confirm first tool │ -│ Do you want to proceed? │ -│ │ -│ ● 1. Allow once │ -│ 2. Allow for this session │ -│ 3. No, suggest changes (esc) │ -│ │ -│ │ -│ ? second-confirm A tool for testing │ -│ │ -│ Test result │ -╰──────────────────────────────────────────────────────────────────────────╯" -`; - -exports[` > Event-Driven Scheduler > hides confirming tools when event-driven scheduler is enabled 1`] = `""`; - -exports[` > Event-Driven Scheduler > renders nothing when only tool is in-progress AskUser with borderBottom=false 1`] = `""`; - -exports[` > Event-Driven Scheduler > shows only successful tools when mixed with confirming tools 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────╮ -│ ✓ success-tool A tool for testing │ -│ │ -│ Test result │ -╰──────────────────────────────────────────────────────────────────────────╯" -`; - exports[` > Golden Snapshots > renders empty tool calls array 1`] = `""`; exports[` > Golden Snapshots > renders header when scrolled 1`] = ` @@ -144,37 +74,21 @@ exports[` > Golden Snapshots > renders mixed tool calls incl │ ⊷ run_shell_command Run command │ │ │ │ Test result │ -│ │ -│ o write_file Write to file │ -│ │ -│ Test result │ ╰──────────────────────────────────────────────────────────────────────────╯" `; -exports[` > Golden Snapshots > renders multiple tool calls with different statuses 1`] = ` +exports[` > Golden Snapshots > renders multiple tool calls with different statuses (only visible ones) 1`] = ` "╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ successful-tool This tool succeeded │ │ │ │ Test result │ │ │ -│ o pending-tool This tool is pending │ -│ │ -│ Test result │ -│ │ │ x error-tool This tool failed │ │ │ │ Test result │ ╰──────────────────────────────────────────────────────────────────────────╯" `; -exports[` > Golden Snapshots > renders shell command with yellow border 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────╮ -│ ✓ run_shell_command Execute shell command │ -│ │ -│ Test result │ -╰──────────────────────────────────────────────────────────────────────────╯" -`; - exports[` > Golden Snapshots > renders single successful tool call 1`] = ` "╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ test-tool A tool for testing │ @@ -183,21 +97,6 @@ exports[` > Golden Snapshots > renders single successful too ╰──────────────────────────────────────────────────────────────────────────╯" `; -exports[` > Golden Snapshots > renders tool call awaiting confirmation 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────╮ -│ ? confirmation-tool This tool needs confirmation ← │ -│ │ -│ Test result │ -│ Are you sure you want to proceed? │ -│ Do you want to proceed? │ -│ │ -│ ● 1. Allow once │ -│ 2. Allow for this session │ -│ 3. No, suggest changes (esc) │ -│ │ -╰──────────────────────────────────────────────────────────────────────────╯" -`; - exports[` > Golden Snapshots > renders tool call with outputFile 1`] = ` "╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ tool-with-file Tool that saved output to file │ @@ -216,14 +115,6 @@ exports[` > Golden Snapshots > renders two tool groups where ╰──────────────────────────────────────────────────────────────────────────╯ █" `; -exports[` > Golden Snapshots > renders when not focused 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────╮ -│ ✓ test-tool A tool for testing │ -│ │ -│ Test result │ -╰──────────────────────────────────────────────────────────────────────────╯" -`; - exports[` > Golden Snapshots > renders with limited terminal height 1`] = ` "╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ tool-with-result Tool with output │ diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 6b3a7db6d97..d31b1e4fbde 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -6,6 +6,7 @@ import { debugLogger, type Config } from '@google/gemini-cli-core'; import { useStdin } from 'ink'; +import { MultiMap } from 'mnemonist'; import type React from 'react'; import { createContext, @@ -26,6 +27,13 @@ export const ESC_TIMEOUT = 50; export const PASTE_TIMEOUT = 30_000; export const FAST_RETURN_TIMEOUT = 30; +export enum KeypressPriority { + Low = -100, + Normal = 0, + High = 100, + Critical = 200, +} + // Parse the key itself const KEY_INFO_MAP: Record< string, @@ -645,7 +653,10 @@ export interface Key { export type KeypressHandler = (key: Key) => boolean | void; interface KeypressContextValue { - subscribe: (handler: KeypressHandler, priority?: boolean) => void; + subscribe: ( + handler: KeypressHandler, + priority?: KeypressPriority | boolean, + ) => void; unsubscribe: (handler: KeypressHandler) => void; } @@ -674,44 +685,75 @@ export function KeypressProvider({ }) { const { stdin, setRawMode } = useStdin(); - const prioritySubscribers = useRef>(new Set()).current; - const normalSubscribers = useRef>(new Set()).current; + const subscribersToPriority = useRef>( + new Map(), + ).current; + const subscribers = useRef( + new MultiMap(Set), + ).current; + const sortedPriorities = useRef([]); const subscribe = useCallback( - (handler: KeypressHandler, priority = false) => { - const set = priority ? prioritySubscribers : normalSubscribers; - set.add(handler); + ( + handler: KeypressHandler, + priority: KeypressPriority | boolean = KeypressPriority.Normal, + ) => { + const p = + typeof priority === 'boolean' + ? priority + ? KeypressPriority.High + : KeypressPriority.Normal + : priority; + + subscribersToPriority.set(handler, p); + const hadPriority = subscribers.has(p); + subscribers.set(p, handler); + + if (!hadPriority) { + // Cache sorted priorities only when a new priority level is added + sortedPriorities.current = Array.from(subscribers.keys()).sort( + (a, b) => b - a, + ); + } }, - [prioritySubscribers, normalSubscribers], + [subscribers, subscribersToPriority], ); const unsubscribe = useCallback( (handler: KeypressHandler) => { - prioritySubscribers.delete(handler); - normalSubscribers.delete(handler); + const p = subscribersToPriority.get(handler); + if (p !== undefined) { + subscribers.remove(p, handler); + subscribersToPriority.delete(handler); + + if (!subscribers.has(p)) { + // Cache sorted priorities only when a priority level is completely removed + sortedPriorities.current = Array.from(subscribers.keys()).sort( + (a, b) => b - a, + ); + } + } }, - [prioritySubscribers, normalSubscribers], + [subscribers, subscribersToPriority], ); const broadcast = useCallback( (key: Key) => { - // Process priority subscribers first, in reverse order (stack behavior: last subscribed is first to handle) - const priorityHandlers = Array.from(prioritySubscribers).reverse(); - for (const handler of priorityHandlers) { - if (handler(key) === true) { - return; - } - } - - // Then process normal subscribers, also in reverse order - const normalHandlers = Array.from(normalSubscribers).reverse(); - for (const handler of normalHandlers) { - if (handler(key) === true) { - return; + // Use cached sorted priorities to avoid sorting on every keypress + for (const p of sortedPriorities.current) { + const set = subscribers.get(p); + if (!set) continue; + + // Within a priority level, use stack behavior (last subscribed is first to handle) + const handlers = Array.from(set).reverse(); + for (const handler of handlers) { + if (handler(key) === true) { + return; + } } } }, - [prioritySubscribers, normalSubscribers], + [subscribers], ); useEffect(() => { diff --git a/packages/cli/src/ui/contexts/SettingsContext.test.tsx b/packages/cli/src/ui/contexts/SettingsContext.test.tsx new file mode 100644 index 00000000000..3124108f900 --- /dev/null +++ b/packages/cli/src/ui/contexts/SettingsContext.test.tsx @@ -0,0 +1,167 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Component, type ReactNode } from 'react'; +import { renderHook, render } from '../../test-utils/render.js'; +import { act } from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SettingsContext, useSettingsStore } from './SettingsContext.js'; +import { + type LoadedSettings, + SettingScope, + type LoadedSettingsSnapshot, + type SettingsFile, + createTestMergedSettings, +} from '../../config/settings.js'; + +const createMockSettingsFile = (path: string): SettingsFile => ({ + path, + settings: {}, + originalSettings: {}, +}); + +const mockSnapshot: LoadedSettingsSnapshot = { + system: createMockSettingsFile('/system'), + systemDefaults: createMockSettingsFile('/defaults'), + user: createMockSettingsFile('/user'), + workspace: createMockSettingsFile('/workspace'), + isTrusted: true, + errors: [], + merged: createTestMergedSettings({ + ui: { theme: 'default-theme' }, + }), +}; + +class ErrorBoundary extends Component< + { children: ReactNode; onError: (error: Error) => void }, + { hasError: boolean } +> { + constructor(props: { children: ReactNode; onError: (error: Error) => void }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(_error: Error) { + return { hasError: true }; + } + + override componentDidCatch(error: Error) { + this.props.onError(error); + } + + override render() { + if (this.state.hasError) { + return null; + } + return this.props.children; + } +} + +const TestHarness = () => { + useSettingsStore(); + return null; +}; + +describe('SettingsContext', () => { + let mockLoadedSettings: LoadedSettings; + let listeners: Array<() => void> = []; + + beforeEach(() => { + listeners = []; + + mockLoadedSettings = { + subscribe: vi.fn((listener: () => void) => { + listeners.push(listener); + return () => { + listeners = listeners.filter((l) => l !== listener); + }; + }), + getSnapshot: vi.fn(() => mockSnapshot), + setValue: vi.fn(), + } as unknown as LoadedSettings; + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + it('should provide the correct initial state', () => { + const { result } = renderHook(() => useSettingsStore(), { wrapper }); + + expect(result.current.settings.merged).toEqual(mockSnapshot.merged); + expect(result.current.settings.isTrusted).toBe(true); + }); + + it('should allow accessing settings for a specific scope', () => { + const { result } = renderHook(() => useSettingsStore(), { wrapper }); + + const userSettings = result.current.settings.forScope(SettingScope.User); + expect(userSettings).toBe(mockSnapshot.user); + + const workspaceSettings = result.current.settings.forScope( + SettingScope.Workspace, + ); + expect(workspaceSettings).toBe(mockSnapshot.workspace); + }); + + it('should trigger re-renders when settings change (external event)', () => { + const { result } = renderHook(() => useSettingsStore(), { wrapper }); + + expect(result.current.settings.merged.ui?.theme).toBe('default-theme'); + + const newSnapshot = { + ...mockSnapshot, + merged: { ui: { theme: 'new-theme' } }, + }; + ( + mockLoadedSettings.getSnapshot as ReturnType + ).mockReturnValue(newSnapshot); + + // Trigger the listeners (simulate coreEvents emission) + act(() => { + listeners.forEach((l) => l()); + }); + + expect(result.current.settings.merged.ui?.theme).toBe('new-theme'); + }); + + it('should call store.setValue when setSetting is called', () => { + const { result } = renderHook(() => useSettingsStore(), { wrapper }); + + act(() => { + result.current.setSetting(SettingScope.User, 'ui.theme', 'dark'); + }); + + expect(mockLoadedSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'ui.theme', + 'dark', + ); + }); + + it('should throw error if used outside provider', () => { + const onError = vi.fn(); + // Suppress console.error (React logs error boundary info) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render( + + + , + ); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'useSettingsStore must be used within a SettingsProvider', + }), + ); + + consoleSpy.mockRestore(); + }); +}); diff --git a/packages/cli/src/ui/contexts/SettingsContext.tsx b/packages/cli/src/ui/contexts/SettingsContext.tsx index 144e1a2859d..2c5ae37dfdf 100644 --- a/packages/cli/src/ui/contexts/SettingsContext.tsx +++ b/packages/cli/src/ui/contexts/SettingsContext.tsx @@ -4,17 +4,81 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useContext } from 'react'; -import type { LoadedSettings } from '../../config/settings.js'; +import React, { useContext, useMemo, useSyncExternalStore } from 'react'; +import type { + LoadableSettingScope, + LoadedSettings, + LoadedSettingsSnapshot, + SettingsFile, +} from '../../config/settings.js'; +import { SettingScope } from '../../config/settings.js'; export const SettingsContext = React.createContext( undefined, ); -export const useSettings = () => { +export const useSettings = (): LoadedSettings => { const context = useContext(SettingsContext); if (context === undefined) { throw new Error('useSettings must be used within a SettingsProvider'); } return context; }; + +export interface SettingsState extends LoadedSettingsSnapshot { + forScope: (scope: LoadableSettingScope) => SettingsFile; +} + +export interface SettingsStoreValue { + settings: SettingsState; + setSetting: ( + scope: LoadableSettingScope, + key: string, + value: unknown, + ) => void; +} + +// Components that call this hook will re render when a settings change event is emitted +export const useSettingsStore = (): SettingsStoreValue => { + const store = useContext(SettingsContext); + if (store === undefined) { + throw new Error('useSettingsStore must be used within a SettingsProvider'); + } + + // React passes a listener fn into the subscribe function + // When the listener runs, it re renders the component if the snapshot changed + const snapshot = useSyncExternalStore( + (listener) => store.subscribe(listener), + () => store.getSnapshot(), + ); + + const settings: SettingsState = useMemo( + () => ({ + ...snapshot, + forScope: (scope: LoadableSettingScope) => { + switch (scope) { + case SettingScope.User: + return snapshot.user; + case SettingScope.Workspace: + return snapshot.workspace; + case SettingScope.System: + return snapshot.system; + case SettingScope.SystemDefaults: + return snapshot.systemDefaults; + default: + throw new Error(`Invalid scope: ${scope}`); + } + }, + }), + [snapshot], + ); + + return useMemo( + () => ({ + settings, + setSetting: (scope: LoadableSettingScope, key: string, value: unknown) => + store.setValue(scope, key, value), + }), + [settings, store], + ); +}; diff --git a/packages/cli/src/ui/hooks/__snapshots__/useToolScheduler.test.ts.snap b/packages/cli/src/ui/hooks/__snapshots__/useToolScheduler.test.ts.snap deleted file mode 100644 index 31953169804..00000000000 --- a/packages/cli/src/ui/hooks/__snapshots__/useToolScheduler.test.ts.snap +++ /dev/null @@ -1,97 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`useReactToolScheduler > should handle live output updates 1`] = ` -{ - "callId": "liveCall", - "contentLength": 12, - "data": undefined, - "error": undefined, - "errorType": undefined, - "outputFile": undefined, - "responseParts": [ - { - "functionResponse": { - "id": "liveCall", - "name": "mockToolWithLiveOutput", - "response": { - "output": "Final output", - }, - }, - }, - ], - "resultDisplay": "Final display", -} -`; - -exports[`useReactToolScheduler > should handle tool requiring confirmation - approved 1`] = ` -{ - "callId": "callConfirm", - "contentLength": 16, - "data": undefined, - "error": undefined, - "errorType": undefined, - "outputFile": undefined, - "responseParts": [ - { - "functionResponse": { - "id": "callConfirm", - "name": "mockToolRequiresConfirmation", - "response": { - "output": "Confirmed output", - }, - }, - }, - ], - "resultDisplay": "Confirmed display", -} -`; - -exports[`useReactToolScheduler > should handle tool requiring confirmation - cancelled by user 1`] = ` -{ - "callId": "callConfirmCancel", - "contentLength": 59, - "error": undefined, - "errorType": undefined, - "responseParts": [ - { - "functionResponse": { - "id": "callConfirmCancel", - "name": "mockToolRequiresConfirmation", - "response": { - "error": "[Operation Cancelled] Reason: User cancelled the operation.", - }, - }, - }, - ], - "resultDisplay": { - "fileDiff": "Mock tool requires confirmation", - "fileName": "mockToolRequiresConfirmation.ts", - "filePath": undefined, - "newContent": undefined, - "originalContent": undefined, - }, -} -`; - -exports[`useReactToolScheduler > should schedule and execute a tool call successfully 1`] = ` -{ - "callId": "call1", - "contentLength": 11, - "data": undefined, - "error": undefined, - "errorType": undefined, - "outputFile": undefined, - "responseParts": [ - { - "functionResponse": { - "id": "call1", - "name": "mockTool", - "response": { - "output": "Tool output", - }, - }, - }, - ], - "resultDisplay": "Formatted tool output", -} -`; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 294c537af4c..ed7168667a3 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -246,7 +246,6 @@ describe('useGeminiStream', () => { getContentGenerator: vi.fn(), isInteractive: () => false, getExperiments: () => {}, - isEventDrivenSchedulerEnabled: vi.fn(() => false), getMaxSessionTurns: vi.fn(() => 100), isJitContextEnabled: vi.fn(() => false), getGlobalMemory: vi.fn(() => ''), diff --git a/packages/cli/src/ui/hooks/useKeypress.ts b/packages/cli/src/ui/hooks/useKeypress.ts index 7df1b195a6e..a06599d2d2e 100644 --- a/packages/cli/src/ui/hooks/useKeypress.ts +++ b/packages/cli/src/ui/hooks/useKeypress.ts @@ -5,8 +5,12 @@ */ import { useEffect } from 'react'; -import type { KeypressHandler, Key } from '../contexts/KeypressContext.js'; -import { useKeypressContext } from '../contexts/KeypressContext.js'; +import { + useKeypressContext, + type KeypressHandler, + type Key, + type KeypressPriority, +} from '../contexts/KeypressContext.js'; export type { Key }; @@ -16,11 +20,14 @@ export type { Key }; * @param onKeypress - The callback function to execute on each keypress. * @param options - Options to control the hook's behavior. * @param options.isActive - Whether the hook should be actively listening for input. - * @param options.priority - Whether the hook should have priority over normal subscribers. + * @param options.priority - Priority level (integer or KeypressPriority enum) or boolean for backward compatibility. */ export function useKeypress( onKeypress: KeypressHandler, - { isActive, priority }: { isActive: boolean; priority?: boolean }, + { + isActive, + priority, + }: { isActive: boolean; priority?: KeypressPriority | boolean }, ) { const { subscribe, unsubscribe } = useKeypressContext(); diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.test.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.test.ts deleted file mode 100644 index ed2c64d2129..00000000000 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { CoreToolScheduler } from '@google/gemini-cli-core'; -import type { Config } from '@google/gemini-cli-core'; -import { renderHook } from '../../test-utils/render.js'; -import { vi, describe, it, expect, beforeEach } from 'vitest'; -import { useReactToolScheduler } from './useReactToolScheduler.js'; - -vi.mock('@google/gemini-cli-core', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - CoreToolScheduler: vi.fn(), - }; -}); - -const mockCoreToolScheduler = vi.mocked(CoreToolScheduler); - -describe('useReactToolScheduler', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('only creates one instance of CoreToolScheduler even if props change', () => { - const onComplete = vi.fn(); - const getPreferredEditor = vi.fn(); - const config = {} as Config; - - const { rerender } = renderHook( - (props) => - useReactToolScheduler( - props.onComplete, - props.config, - props.getPreferredEditor, - ), - { - initialProps: { - onComplete, - config, - getPreferredEditor, - }, - }, - ); - - expect(mockCoreToolScheduler).toHaveBeenCalledTimes(1); - - // Rerender with a new onComplete function - const newOnComplete = vi.fn(); - rerender({ - onComplete: newOnComplete, - config, - getPreferredEditor, - }); - expect(mockCoreToolScheduler).toHaveBeenCalledTimes(1); - - // Rerender with a new getPreferredEditor function - const newGetPreferredEditor = vi.fn(); - rerender({ - onComplete: newOnComplete, - config, - getPreferredEditor: newGetPreferredEditor, - }); - expect(mockCoreToolScheduler).toHaveBeenCalledTimes(1); - - rerender({ - onComplete: newOnComplete, - config, - getPreferredEditor: newGetPreferredEditor, - }); - expect(mockCoreToolScheduler).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts deleted file mode 100644 index cd17b305b57..00000000000 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ /dev/null @@ -1,221 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { - Config, - ToolCallRequestInfo, - OutputUpdateHandler, - AllToolCallsCompleteHandler, - ToolCallsUpdateHandler, - ToolCall, - EditorType, - CompletedToolCall, - ExecutingToolCall, - ScheduledToolCall, - ValidatingToolCall, - WaitingToolCall, - CancelledToolCall, -} from '@google/gemini-cli-core'; -import { CoreToolScheduler } from '@google/gemini-cli-core'; -import { useCallback, useState, useMemo, useEffect, useRef } from 'react'; - -export type ScheduleFn = ( - request: ToolCallRequestInfo | ToolCallRequestInfo[], - signal: AbortSignal, -) => Promise; -export type MarkToolsAsSubmittedFn = (callIds: string[]) => void; -export type CancelAllFn = (signal: AbortSignal) => void; - -export type TrackedScheduledToolCall = ScheduledToolCall & { - responseSubmittedToGemini?: boolean; -}; -export type TrackedValidatingToolCall = ValidatingToolCall & { - responseSubmittedToGemini?: boolean; -}; -export type TrackedWaitingToolCall = WaitingToolCall & { - responseSubmittedToGemini?: boolean; -}; -export type TrackedExecutingToolCall = ExecutingToolCall & { - responseSubmittedToGemini?: boolean; -}; -export type TrackedCompletedToolCall = CompletedToolCall & { - responseSubmittedToGemini?: boolean; -}; -export type TrackedCancelledToolCall = CancelledToolCall & { - responseSubmittedToGemini?: boolean; -}; - -export type TrackedToolCall = - | TrackedScheduledToolCall - | TrackedValidatingToolCall - | TrackedWaitingToolCall - | TrackedExecutingToolCall - | TrackedCompletedToolCall - | TrackedCancelledToolCall; - -/** - * Legacy scheduler implementation based on CoreToolScheduler callbacks. - * - * This is currently the default implementation used by useGeminiStream. - * It will be phased out once the event-driven scheduler migration is complete. - */ -export function useReactToolScheduler( - onComplete: (tools: CompletedToolCall[]) => Promise, - config: Config, - getPreferredEditor: () => EditorType | undefined, -): [ - TrackedToolCall[], - ScheduleFn, - MarkToolsAsSubmittedFn, - React.Dispatch>, - CancelAllFn, - number, -] { - const [toolCallsForDisplay, setToolCallsForDisplay] = useState< - TrackedToolCall[] - >([]); - const [lastToolOutputTime, setLastToolOutputTime] = useState(0); - - const onCompleteRef = useRef(onComplete); - const getPreferredEditorRef = useRef(getPreferredEditor); - - useEffect(() => { - onCompleteRef.current = onComplete; - }, [onComplete]); - - useEffect(() => { - getPreferredEditorRef.current = getPreferredEditor; - }, [getPreferredEditor]); - - const outputUpdateHandler: OutputUpdateHandler = useCallback( - (toolCallId, outputChunk) => { - setLastToolOutputTime(Date.now()); - setToolCallsForDisplay((prevCalls) => - prevCalls.map((tc) => { - if (tc.request.callId === toolCallId && tc.status === 'executing') { - const executingTc = tc; - return { ...executingTc, liveOutput: outputChunk }; - } - return tc; - }), - ); - }, - [], - ); - - const allToolCallsCompleteHandler: AllToolCallsCompleteHandler = useCallback( - async (completedToolCalls) => { - await onCompleteRef.current(completedToolCalls); - }, - [], - ); - - const toolCallsUpdateHandler: ToolCallsUpdateHandler = useCallback( - (allCoreToolCalls: ToolCall[]) => { - setToolCallsForDisplay((prevTrackedCalls) => { - const prevCallsMap = new Map( - prevTrackedCalls.map((c) => [c.request.callId, c]), - ); - - return allCoreToolCalls.map((coreTc): TrackedToolCall => { - const existingTrackedCall = prevCallsMap.get(coreTc.request.callId); - - const responseSubmittedToGemini = - existingTrackedCall?.responseSubmittedToGemini ?? false; - - if (coreTc.status === 'executing') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const liveOutput = (existingTrackedCall as TrackedExecutingToolCall) - ?.liveOutput; - return { - ...coreTc, - responseSubmittedToGemini, - liveOutput, - }; - } else if ( - coreTc.status === 'success' || - coreTc.status === 'error' || - coreTc.status === 'cancelled' - ) { - return { - ...coreTc, - responseSubmittedToGemini, - }; - } else { - return { - ...coreTc, - responseSubmittedToGemini, - }; - } - }); - }); - }, - [setToolCallsForDisplay], - ); - - const stableGetPreferredEditor = useCallback( - () => getPreferredEditorRef.current(), - [], - ); - - const scheduler = useMemo( - () => - new CoreToolScheduler({ - outputUpdateHandler, - onAllToolCallsComplete: allToolCallsCompleteHandler, - onToolCallsUpdate: toolCallsUpdateHandler, - getPreferredEditor: stableGetPreferredEditor, - config, - }), - [ - config, - outputUpdateHandler, - allToolCallsCompleteHandler, - toolCallsUpdateHandler, - stableGetPreferredEditor, - ], - ); - - const schedule: ScheduleFn = useCallback( - ( - request: ToolCallRequestInfo | ToolCallRequestInfo[], - signal: AbortSignal, - ) => { - setToolCallsForDisplay([]); - return scheduler.schedule(request, signal); - }, - [scheduler, setToolCallsForDisplay], - ); - - const markToolsAsSubmitted: MarkToolsAsSubmittedFn = useCallback( - (callIdsToMark: string[]) => { - setToolCallsForDisplay((prevCalls) => - prevCalls.map((tc) => - callIdsToMark.includes(tc.request.callId) - ? { ...tc, responseSubmittedToGemini: true } - : tc, - ), - ); - }, - [], - ); - - const cancelAllToolCalls = useCallback( - (signal: AbortSignal) => { - scheduler.cancelAll(signal); - }, - [scheduler], - ); - - return [ - toolCallsForDisplay, - schedule, - markToolsAsSubmitted, - setToolCallsForDisplay, - cancelAllToolCalls, - lastToolOutputTime, - ]; -} diff --git a/packages/cli/src/ui/hooks/useShellInactivityStatus.ts b/packages/cli/src/ui/hooks/useShellInactivityStatus.ts index d0e5c0706d7..092e58baaea 100644 --- a/packages/cli/src/ui/hooks/useShellInactivityStatus.ts +++ b/packages/cli/src/ui/hooks/useShellInactivityStatus.ts @@ -12,7 +12,7 @@ import { SHELL_SILENT_WORKING_TITLE_DELAY_MS, } from '../constants.js'; import type { StreamingState } from '../types.js'; -import { type TrackedToolCall } from './useReactToolScheduler.js'; +import { type TrackedToolCall } from './useToolScheduler.js'; interface ShellInactivityStatusProps { activePtyId: number | string | null | undefined; diff --git a/packages/cli/src/ui/hooks/useToolExecutionScheduler.test.ts b/packages/cli/src/ui/hooks/useToolExecutionScheduler.test.ts deleted file mode 100644 index 797109499b5..00000000000 --- a/packages/cli/src/ui/hooks/useToolExecutionScheduler.test.ts +++ /dev/null @@ -1,525 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { act } from 'react'; -import { renderHook } from '../../test-utils/render.js'; -import { useToolExecutionScheduler } from './useToolExecutionScheduler.js'; -import { - MessageBusType, - ToolConfirmationOutcome, - Scheduler, - type Config, - type MessageBus, - type CompletedToolCall, - type ToolCallConfirmationDetails, - type ToolCallsUpdateMessage, - type AnyDeclarativeTool, - type AnyToolInvocation, - ROOT_SCHEDULER_ID, -} from '@google/gemini-cli-core'; -import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js'; - -// Mock Core Scheduler -vi.mock('@google/gemini-cli-core', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - Scheduler: vi.fn().mockImplementation(() => ({ - schedule: vi.fn().mockResolvedValue([]), - cancelAll: vi.fn(), - })), - }; -}); - -const createMockTool = ( - overrides: Partial = {}, -): AnyDeclarativeTool => - ({ - name: 'test_tool', - displayName: 'Test Tool', - description: 'A test tool', - kind: 'function', - parameterSchema: {}, - isOutputMarkdown: false, - build: vi.fn(), - ...overrides, - }) as AnyDeclarativeTool; - -const createMockInvocation = ( - overrides: Partial = {}, -): AnyToolInvocation => - ({ - getDescription: () => 'Executing test tool', - shouldConfirmExecute: vi.fn(), - execute: vi.fn(), - params: {}, - toolLocations: [], - ...overrides, - }) as AnyToolInvocation; - -describe('useToolExecutionScheduler', () => { - let mockConfig: Config; - let mockMessageBus: MessageBus; - - beforeEach(() => { - vi.clearAllMocks(); - mockMessageBus = createMockMessageBus() as unknown as MessageBus; - mockConfig = { - getMessageBus: () => mockMessageBus, - } as unknown as Config; - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('initializes with empty tool calls', () => { - const { result } = renderHook(() => - useToolExecutionScheduler( - vi.fn().mockResolvedValue(undefined), - mockConfig, - () => undefined, - ), - ); - const [toolCalls] = result.current; - expect(toolCalls).toEqual([]); - }); - - it('updates tool calls when MessageBus emits TOOL_CALLS_UPDATE', () => { - const { result } = renderHook(() => - useToolExecutionScheduler( - vi.fn().mockResolvedValue(undefined), - mockConfig, - () => undefined, - ), - ); - - const mockToolCall = { - status: 'executing' as const, - request: { - callId: 'call-1', - name: 'test_tool', - args: {}, - isClientInitiated: false, - prompt_id: 'p1', - }, - tool: createMockTool(), - invocation: createMockInvocation(), - liveOutput: 'Loading...', - }; - - act(() => { - void mockMessageBus.publish({ - type: MessageBusType.TOOL_CALLS_UPDATE, - toolCalls: [mockToolCall], - schedulerId: ROOT_SCHEDULER_ID, - } as ToolCallsUpdateMessage); - }); - - const [toolCalls] = result.current; - expect(toolCalls).toHaveLength(1); - // Expect Core Object structure, not Display Object - expect(toolCalls[0]).toMatchObject({ - request: { callId: 'call-1', name: 'test_tool' }, - status: 'executing', // Core status - liveOutput: 'Loading...', - responseSubmittedToGemini: false, - }); - }); - - it('injects onConfirm callback for awaiting_approval tools (Adapter Pattern)', async () => { - const { result } = renderHook(() => - useToolExecutionScheduler( - vi.fn().mockResolvedValue(undefined), - mockConfig, - () => undefined, - ), - ); - - const mockToolCall = { - status: 'awaiting_approval' as const, - request: { - callId: 'call-1', - name: 'test_tool', - args: {}, - isClientInitiated: false, - prompt_id: 'p1', - }, - tool: createMockTool(), - invocation: createMockInvocation({ - getDescription: () => 'Confirming test tool', - }), - confirmationDetails: { type: 'info', title: 'Confirm', prompt: 'Sure?' }, - correlationId: 'corr-123', - }; - - act(() => { - void mockMessageBus.publish({ - type: MessageBusType.TOOL_CALLS_UPDATE, - toolCalls: [mockToolCall], - schedulerId: ROOT_SCHEDULER_ID, - } as ToolCallsUpdateMessage); - }); - - const [toolCalls] = result.current; - const call = toolCalls[0]; - if (call.status !== 'awaiting_approval') { - throw new Error('Expected status to be awaiting_approval'); - } - const confirmationDetails = - call.confirmationDetails as ToolCallConfirmationDetails; - - expect(confirmationDetails).toBeDefined(); - expect(typeof confirmationDetails.onConfirm).toBe('function'); - - // Test that onConfirm publishes to MessageBus - const publishSpy = vi.spyOn(mockMessageBus, 'publish'); - await confirmationDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce); - - expect(publishSpy).toHaveBeenCalledWith({ - type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, - correlationId: 'corr-123', - confirmed: true, - requiresUserConfirmation: false, - outcome: ToolConfirmationOutcome.ProceedOnce, - payload: undefined, - }); - }); - - it('injects onConfirm with payload (Inline Edit support)', async () => { - const { result } = renderHook(() => - useToolExecutionScheduler( - vi.fn().mockResolvedValue(undefined), - mockConfig, - () => undefined, - ), - ); - - const mockToolCall = { - status: 'awaiting_approval' as const, - request: { - callId: 'call-1', - name: 'test_tool', - args: {}, - isClientInitiated: false, - prompt_id: 'p1', - }, - tool: createMockTool(), - invocation: createMockInvocation(), - confirmationDetails: { type: 'edit', title: 'Edit', filePath: 'test.ts' }, - correlationId: 'corr-edit', - }; - - act(() => { - void mockMessageBus.publish({ - type: MessageBusType.TOOL_CALLS_UPDATE, - toolCalls: [mockToolCall], - schedulerId: ROOT_SCHEDULER_ID, - } as ToolCallsUpdateMessage); - }); - - const [toolCalls] = result.current; - const call = toolCalls[0]; - if (call.status !== 'awaiting_approval') { - throw new Error('Expected awaiting_approval'); - } - const confirmationDetails = - call.confirmationDetails as ToolCallConfirmationDetails; - - const publishSpy = vi.spyOn(mockMessageBus, 'publish'); - const mockPayload = { newContent: 'updated code' }; - await confirmationDetails.onConfirm( - ToolConfirmationOutcome.ProceedOnce, - mockPayload, - ); - - expect(publishSpy).toHaveBeenCalledWith({ - type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, - correlationId: 'corr-edit', - confirmed: true, - requiresUserConfirmation: false, - outcome: ToolConfirmationOutcome.ProceedOnce, - payload: mockPayload, - }); - }); - - it('preserves responseSubmittedToGemini flag across updates', () => { - const { result } = renderHook(() => - useToolExecutionScheduler( - vi.fn().mockResolvedValue(undefined), - mockConfig, - () => undefined, - ), - ); - - const mockToolCall = { - status: 'success' as const, - request: { - callId: 'call-1', - name: 'test', - args: {}, - isClientInitiated: false, - prompt_id: 'p1', - }, - tool: createMockTool(), - invocation: createMockInvocation(), - response: { - callId: 'call-1', - resultDisplay: 'OK', - responseParts: [], - error: undefined, - errorType: undefined, - }, - }; - - // 1. Initial success - act(() => { - void mockMessageBus.publish({ - type: MessageBusType.TOOL_CALLS_UPDATE, - toolCalls: [mockToolCall], - schedulerId: ROOT_SCHEDULER_ID, - } as ToolCallsUpdateMessage); - }); - - // 2. Mark as submitted - act(() => { - const [, , markAsSubmitted] = result.current; - markAsSubmitted(['call-1']); - }); - - expect(result.current[0][0].responseSubmittedToGemini).toBe(true); - - // 3. Receive another update (should preserve the true flag) - act(() => { - void mockMessageBus.publish({ - type: MessageBusType.TOOL_CALLS_UPDATE, - toolCalls: [mockToolCall], - schedulerId: ROOT_SCHEDULER_ID, - } as ToolCallsUpdateMessage); - }); - - expect(result.current[0][0].responseSubmittedToGemini).toBe(true); - }); - - it('updates lastToolOutputTime when tools are executing', () => { - vi.useFakeTimers(); - const { result } = renderHook(() => - useToolExecutionScheduler( - vi.fn().mockResolvedValue(undefined), - mockConfig, - () => undefined, - ), - ); - - const startTime = Date.now(); - vi.advanceTimersByTime(1000); - - act(() => { - void mockMessageBus.publish({ - type: MessageBusType.TOOL_CALLS_UPDATE, - toolCalls: [ - { - status: 'executing' as const, - request: { - callId: 'call-1', - name: 'test', - args: {}, - isClientInitiated: false, - prompt_id: 'p1', - }, - tool: createMockTool(), - invocation: createMockInvocation(), - }, - ], - schedulerId: ROOT_SCHEDULER_ID, - } as ToolCallsUpdateMessage); - }); - - const [, , , , , lastOutputTime] = result.current; - expect(lastOutputTime).toBeGreaterThan(startTime); - vi.useRealTimers(); - }); - - it('delegates cancelAll to the Core Scheduler', () => { - const { result } = renderHook(() => - useToolExecutionScheduler( - vi.fn().mockResolvedValue(undefined), - mockConfig, - () => undefined, - ), - ); - - const [, , , , cancelAll] = result.current; - const signal = new AbortController().signal; - - // We need to find the mock instance of Scheduler - // Since we used vi.mock at top level, we can get it from vi.mocked(Scheduler) - const schedulerInstance = vi.mocked(Scheduler).mock.results[0].value; - - cancelAll(signal); - - expect(schedulerInstance.cancelAll).toHaveBeenCalled(); - }); - - it('resolves the schedule promise when scheduler resolves', async () => { - const onComplete = vi.fn().mockResolvedValue(undefined); - - const completedToolCall = { - status: 'success' as const, - request: { - callId: 'call-1', - name: 'test', - args: {}, - isClientInitiated: false, - prompt_id: 'p1', - }, - tool: createMockTool(), - invocation: createMockInvocation(), - response: { - callId: 'call-1', - responseParts: [], - resultDisplay: 'Success', - error: undefined, - errorType: undefined, - }, - }; - - // Mock the specific return value for this test - const { Scheduler } = await import('@google/gemini-cli-core'); - vi.mocked(Scheduler).mockImplementation( - () => - ({ - schedule: vi.fn().mockResolvedValue([completedToolCall]), - cancelAll: vi.fn(), - }) as unknown as Scheduler, - ); - - const { result } = renderHook(() => - useToolExecutionScheduler(onComplete, mockConfig, () => undefined), - ); - - const [, schedule] = result.current; - const signal = new AbortController().signal; - - let completedResult: CompletedToolCall[] = []; - await act(async () => { - completedResult = await schedule( - { - callId: 'call-1', - name: 'test', - args: {}, - isClientInitiated: false, - prompt_id: 'p1', - }, - signal, - ); - }); - - expect(completedResult).toEqual([completedToolCall]); - expect(onComplete).toHaveBeenCalledWith([completedToolCall]); - }); - - it('setToolCallsForDisplay re-groups tools by schedulerId (Multi-Scheduler support)', () => { - const { result } = renderHook(() => - useToolExecutionScheduler( - vi.fn().mockResolvedValue(undefined), - mockConfig, - () => undefined, - ), - ); - - const callRoot = { - status: 'success' as const, - request: { - callId: 'call-root', - name: 'test', - args: {}, - isClientInitiated: false, - prompt_id: 'p1', - }, - tool: createMockTool(), - invocation: createMockInvocation(), - response: { - callId: 'call-root', - responseParts: [], - resultDisplay: 'OK', - error: undefined, - errorType: undefined, - }, - schedulerId: ROOT_SCHEDULER_ID, - }; - - const callSub = { - ...callRoot, - request: { ...callRoot.request, callId: 'call-sub' }, - schedulerId: 'subagent-1', - }; - - // 1. Populate state with multiple schedulers - act(() => { - void mockMessageBus.publish({ - type: MessageBusType.TOOL_CALLS_UPDATE, - toolCalls: [callRoot], - schedulerId: ROOT_SCHEDULER_ID, - } as ToolCallsUpdateMessage); - - void mockMessageBus.publish({ - type: MessageBusType.TOOL_CALLS_UPDATE, - toolCalls: [callSub], - schedulerId: 'subagent-1', - } as ToolCallsUpdateMessage); - }); - - let [toolCalls] = result.current; - expect(toolCalls).toHaveLength(2); - expect( - toolCalls.find((t) => t.request.callId === 'call-root')?.schedulerId, - ).toBe(ROOT_SCHEDULER_ID); - expect( - toolCalls.find((t) => t.request.callId === 'call-sub')?.schedulerId, - ).toBe('subagent-1'); - - // 2. Call setToolCallsForDisplay (e.g., simulate a manual update or clear) - act(() => { - const [, , , setToolCalls] = result.current; - setToolCalls((prev) => - prev.map((t) => ({ ...t, responseSubmittedToGemini: true })), - ); - }); - - // 3. Verify that tools are still present and maintain their scheduler IDs - // The internal map should have been re-grouped. - [toolCalls] = result.current; - expect(toolCalls).toHaveLength(2); - expect(toolCalls.every((t) => t.responseSubmittedToGemini)).toBe(true); - - const updatedRoot = toolCalls.find((t) => t.request.callId === 'call-root'); - const updatedSub = toolCalls.find((t) => t.request.callId === 'call-sub'); - - expect(updatedRoot?.schedulerId).toBe(ROOT_SCHEDULER_ID); - expect(updatedSub?.schedulerId).toBe('subagent-1'); - - // 4. Verify that a subsequent update to ONE scheduler doesn't wipe the other - act(() => { - void mockMessageBus.publish({ - type: MessageBusType.TOOL_CALLS_UPDATE, - toolCalls: [{ ...callRoot, status: 'executing' }], - schedulerId: ROOT_SCHEDULER_ID, - } as ToolCallsUpdateMessage); - }); - - [toolCalls] = result.current; - expect(toolCalls).toHaveLength(2); - expect( - toolCalls.find((t) => t.request.callId === 'call-root')?.status, - ).toBe('executing'); - expect( - toolCalls.find((t) => t.request.callId === 'call-sub')?.schedulerId, - ).toBe('subagent-1'); - }); -}); diff --git a/packages/cli/src/ui/hooks/useToolExecutionScheduler.ts b/packages/cli/src/ui/hooks/useToolExecutionScheduler.ts deleted file mode 100644 index 0c58e7fc415..00000000000 --- a/packages/cli/src/ui/hooks/useToolExecutionScheduler.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - type Config, - type MessageBus, - type ToolCallRequestInfo, - type ToolCall, - type CompletedToolCall, - type ToolConfirmationPayload, - MessageBusType, - ToolConfirmationOutcome, - Scheduler, - type EditorType, - type ToolCallsUpdateMessage, - ROOT_SCHEDULER_ID, -} from '@google/gemini-cli-core'; -import { useCallback, useState, useMemo, useEffect, useRef } from 'react'; - -// Re-exporting types compatible with legacy hook expectations -export type ScheduleFn = ( - request: ToolCallRequestInfo | ToolCallRequestInfo[], - signal: AbortSignal, -) => Promise; - -export type MarkToolsAsSubmittedFn = (callIds: string[]) => void; -export type CancelAllFn = (signal: AbortSignal) => void; - -/** - * The shape expected by useGeminiStream. - * It matches the Core ToolCall structure + the UI metadata flag. - */ -export type TrackedToolCall = ToolCall & { - responseSubmittedToGemini?: boolean; -}; - -/** - * Modern tool scheduler hook using the event-driven Core Scheduler. - * - * This hook acts as an Adapter between the new MessageBus-driven Core - * and the legacy callback-based UI components. - */ -export function useToolExecutionScheduler( - onComplete: (tools: CompletedToolCall[]) => Promise, - config: Config, - getPreferredEditor: () => EditorType | undefined, -): [ - TrackedToolCall[], - ScheduleFn, - MarkToolsAsSubmittedFn, - React.Dispatch>, - CancelAllFn, - number, -] { - // State stores tool calls organized by their originating schedulerId - const [toolCallsMap, setToolCallsMap] = useState< - Record - >({}); - const [lastToolOutputTime, setLastToolOutputTime] = useState(0); - - const messageBus = useMemo(() => config.getMessageBus(), [config]); - - const onCompleteRef = useRef(onComplete); - useEffect(() => { - onCompleteRef.current = onComplete; - }, [onComplete]); - - const getPreferredEditorRef = useRef(getPreferredEditor); - useEffect(() => { - getPreferredEditorRef.current = getPreferredEditor; - }, [getPreferredEditor]); - - const scheduler = useMemo( - () => - new Scheduler({ - config, - messageBus, - getPreferredEditor: () => getPreferredEditorRef.current(), - schedulerId: ROOT_SCHEDULER_ID, - }), - [config, messageBus], - ); - - const internalAdaptToolCalls = useCallback( - (coreCalls: ToolCall[], prevTracked: TrackedToolCall[]) => - adaptToolCalls(coreCalls, prevTracked, messageBus), - [messageBus], - ); - - useEffect(() => { - const handler = (event: ToolCallsUpdateMessage) => { - // Update output timer for UI spinners (Side Effect) - if (event.toolCalls.some((tc) => tc.status === 'executing')) { - setLastToolOutputTime(Date.now()); - } - - setToolCallsMap((prev) => { - const adapted = internalAdaptToolCalls( - event.toolCalls, - prev[event.schedulerId] ?? [], - ); - - return { - ...prev, - [event.schedulerId]: adapted, - }; - }); - }; - - messageBus.subscribe(MessageBusType.TOOL_CALLS_UPDATE, handler); - return () => { - messageBus.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, handler); - }; - }, [messageBus, internalAdaptToolCalls]); - - const schedule: ScheduleFn = useCallback( - async (request, signal) => { - // Clear state for new run - setToolCallsMap({}); - - // 1. Await Core Scheduler directly - const results = await scheduler.schedule(request, signal); - - // 2. Trigger legacy reinjection logic (useGeminiStream loop) - // Since this hook instance owns the "root" scheduler, we always trigger - // onComplete when it finishes its batch. - await onCompleteRef.current(results); - - return results; - }, - [scheduler], - ); - - const cancelAll: CancelAllFn = useCallback( - (_signal) => { - scheduler.cancelAll(); - }, - [scheduler], - ); - - const markToolsAsSubmitted: MarkToolsAsSubmittedFn = useCallback( - (callIdsToMark: string[]) => { - setToolCallsMap((prevMap) => { - const nextMap = { ...prevMap }; - for (const [sid, calls] of Object.entries(nextMap)) { - nextMap[sid] = calls.map((tc) => - callIdsToMark.includes(tc.request.callId) - ? { ...tc, responseSubmittedToGemini: true } - : tc, - ); - } - return nextMap; - }); - }, - [], - ); - - // Flatten the map for the UI components that expect a single list of tools. - const toolCalls = useMemo( - () => Object.values(toolCallsMap).flat(), - [toolCallsMap], - ); - - // Provide a setter that maintains compatibility with legacy []. - const setToolCallsForDisplay = useCallback( - (action: React.SetStateAction) => { - setToolCallsMap((prev) => { - const currentFlattened = Object.values(prev).flat(); - const nextFlattened = - typeof action === 'function' ? action(currentFlattened) : action; - - if (nextFlattened.length === 0) { - return {}; - } - - // Re-group by schedulerId to preserve multi-scheduler state - const nextMap: Record = {}; - for (const call of nextFlattened) { - // All tool calls should have a schedulerId from the core. - // Default to ROOT_SCHEDULER_ID as a safeguard. - const sid = call.schedulerId ?? ROOT_SCHEDULER_ID; - if (!nextMap[sid]) { - nextMap[sid] = []; - } - nextMap[sid].push(call); - } - return nextMap; - }); - }, - [], - ); - - return [ - toolCalls, - schedule, - markToolsAsSubmitted, - setToolCallsForDisplay, - cancelAll, - lastToolOutputTime, - ]; -} - -/** - * ADAPTER: Merges UI metadata (submitted flag) and injects legacy callbacks. - */ -function adaptToolCalls( - coreCalls: ToolCall[], - prevTracked: TrackedToolCall[], - messageBus: MessageBus, -): TrackedToolCall[] { - const prevMap = new Map(prevTracked.map((t) => [t.request.callId, t])); - - return coreCalls.map((coreCall): TrackedToolCall => { - const prev = prevMap.get(coreCall.request.callId); - const responseSubmittedToGemini = prev?.responseSubmittedToGemini ?? false; - - // Inject onConfirm adapter for tools awaiting approval. - // The Core provides data-only (serializable) confirmationDetails. We must - // inject the legacy callback function that proxies responses back to the - // MessageBus. - if (coreCall.status === 'awaiting_approval' && coreCall.correlationId) { - const correlationId = coreCall.correlationId; - return { - ...coreCall, - confirmationDetails: { - ...coreCall.confirmationDetails, - onConfirm: async ( - outcome: ToolConfirmationOutcome, - payload?: ToolConfirmationPayload, - ) => { - await messageBus.publish({ - type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, - correlationId, - confirmed: outcome !== ToolConfirmationOutcome.Cancel, - requiresUserConfirmation: false, - outcome, - payload, - }); - }, - }, - responseSubmittedToGemini, - }; - } - - return { - ...coreCall, - responseSubmittedToGemini, - }; - }); -} diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index 81cafb4f345..4a04d6225c1 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -1,1135 +1,525 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import type { Mock } from 'vitest'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { act } from 'react'; import { renderHook } from '../../test-utils/render.js'; -import { useReactToolScheduler } from './useReactToolScheduler.js'; -import { mapToDisplay } from './toolMapping.js'; -import type { PartUnion, FunctionResponse } from '@google/genai'; -import type { - Config, - ToolCallRequestInfo, - ToolRegistry, - ToolResult, - ToolCallConfirmationDetails, - ToolCallResponseInfo, - ToolCall, // Import from core - Status as ToolCallStatusType, - AnyDeclarativeTool, - AnyToolInvocation, -} from '@google/gemini-cli-core'; +import { useToolScheduler } from './useToolScheduler.js'; import { - DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + MessageBusType, ToolConfirmationOutcome, - ApprovalMode, - HookSystem, - PREVIEW_GEMINI_MODEL, - PolicyDecision, + Scheduler, + type Config, + type MessageBus, + type CompletedToolCall, + type ToolCallConfirmationDetails, + type ToolCallsUpdateMessage, + type AnyDeclarativeTool, + type AnyToolInvocation, + ROOT_SCHEDULER_ID, } from '@google/gemini-cli-core'; -import { MockTool } from '@google/gemini-cli-core/src/test-utils/mock-tool.js'; import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js'; -import { ToolCallStatus } from '../types.js'; -// Mocks -vi.mock('@google/gemini-cli-core', async () => { - const actual = await vi.importActual('@google/gemini-cli-core'); - // Patch CoreToolScheduler to have cancelAll if it's missing in the test environment - if ( - actual.CoreToolScheduler && - !actual.CoreToolScheduler.prototype.cancelAll - ) { - actual.CoreToolScheduler.prototype.cancelAll = vi.fn(); - } +// Mock Core Scheduler +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); return { ...actual, - ToolRegistry: vi.fn(), - Config: vi.fn(), + Scheduler: vi.fn().mockImplementation(() => ({ + schedule: vi.fn().mockResolvedValue([]), + cancelAll: vi.fn(), + })), }; }); -const mockToolRegistry = { - getTool: vi.fn(), - getAllToolNames: vi.fn(() => ['mockTool', 'anotherTool']), -}; - -const mockConfig = { - getToolRegistry: vi.fn(() => mockToolRegistry as unknown as ToolRegistry), - getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT), - getSessionId: () => 'test-session-id', - getUsageStatisticsEnabled: () => true, - getDebugMode: () => false, - getWorkingDir: () => '/working/dir', - storage: { - getProjectTempDir: () => '/tmp', - }, - getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, - getAllowedTools: vi.fn(() => []), - getActiveModel: () => PREVIEW_GEMINI_MODEL, - getContentGeneratorConfig: () => ({ - model: 'test-model', - authType: 'oauth-personal', - }), - getGeminiClient: () => null, // No client needed for these tests - getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24 }), - getMessageBus: () => null, - isInteractive: () => false, - getExperiments: () => {}, - getEnableHooks: () => false, -} as unknown as Config; -mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus()); -mockConfig.getHookSystem = vi.fn().mockReturnValue(new HookSystem(mockConfig)); -mockConfig.getPolicyEngine = vi.fn().mockReturnValue({ - check: async () => { - const mode = mockConfig.getApprovalMode(); - if (mode === ApprovalMode.YOLO) { - return { decision: PolicyDecision.ALLOW }; - } - return { decision: PolicyDecision.ASK_USER }; - }, -}); - -function createMockConfigOverride(overrides: Partial = {}): Config { - return { ...mockConfig, ...overrides } as Config; -} - -const mockTool = new MockTool({ - name: 'mockTool', - displayName: 'Mock Tool', - execute: vi.fn(), - shouldConfirmExecute: vi.fn(), -}); -const mockToolWithLiveOutput = new MockTool({ - name: 'mockToolWithLiveOutput', - displayName: 'Mock Tool With Live Output', - description: 'A mock tool for testing', - params: {}, - isOutputMarkdown: true, - canUpdateOutput: true, - execute: vi.fn(), - shouldConfirmExecute: vi.fn(), -}); -let mockOnUserConfirmForToolConfirmation: Mock; -const mockToolRequiresConfirmation = new MockTool({ - name: 'mockToolRequiresConfirmation', - displayName: 'Mock Tool Requires Confirmation', - execute: vi.fn(), - shouldConfirmExecute: vi.fn(), -}); +const createMockTool = ( + overrides: Partial = {}, +): AnyDeclarativeTool => + ({ + name: 'test_tool', + displayName: 'Test Tool', + description: 'A test tool', + kind: 'function', + parameterSchema: {}, + isOutputMarkdown: false, + build: vi.fn(), + ...overrides, + }) as AnyDeclarativeTool; + +const createMockInvocation = ( + overrides: Partial = {}, +): AnyToolInvocation => + ({ + getDescription: () => 'Executing test tool', + shouldConfirmExecute: vi.fn(), + execute: vi.fn(), + params: {}, + toolLocations: [], + ...overrides, + }) as AnyToolInvocation; -describe('useReactToolScheduler in YOLO Mode', () => { - let onComplete: Mock; +describe('useToolScheduler', () => { + let mockConfig: Config; + let mockMessageBus: MessageBus; beforeEach(() => { - onComplete = vi.fn(); - mockToolRegistry.getTool.mockClear(); - (mockToolRequiresConfirmation.execute as Mock).mockClear(); - (mockToolRequiresConfirmation.shouldConfirmExecute as Mock).mockClear(); - - // IMPORTANT: Enable YOLO mode for this test suite - (mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO); - - vi.useFakeTimers(); + vi.clearAllMocks(); + mockMessageBus = createMockMessageBus() as unknown as MessageBus; + mockConfig = { + getMessageBus: () => mockMessageBus, + } as unknown as Config; }); afterEach(() => { - vi.clearAllTimers(); - vi.useRealTimers(); - // IMPORTANT: Disable YOLO mode after this test suite - (mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.DEFAULT); + vi.clearAllMocks(); }); - const renderSchedulerInYoloMode = () => - renderHook(() => - useReactToolScheduler( - onComplete, - mockConfig as unknown as Config, + it('initializes with empty tool calls', () => { + const { result } = renderHook(() => + useToolScheduler( + vi.fn().mockResolvedValue(undefined), + mockConfig, () => undefined, ), ); - - it('should skip confirmation and execute tool directly when yoloMode is true', async () => { - mockToolRegistry.getTool.mockReturnValue(mockToolRequiresConfirmation); - const expectedOutput = 'YOLO Confirmed output'; - (mockToolRequiresConfirmation.execute as Mock).mockResolvedValue({ - llmContent: expectedOutput, - returnDisplay: 'YOLO Formatted tool output', - } as ToolResult); - - const { result } = renderSchedulerInYoloMode(); - const schedule = result.current[1]; - const request: ToolCallRequestInfo = { - callId: 'yoloCall', - name: 'mockToolRequiresConfirmation', - args: { data: 'any data' }, - } as any; - - await act(async () => { - await schedule(request, new AbortController().signal); - }); - - await act(async () => { - await vi.advanceTimersByTimeAsync(0); // Process validation - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(0); // Process scheduling - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(0); // Process execution - }); - - // Check that execute WAS called - expect(mockToolRequiresConfirmation.execute).toHaveBeenCalledWith( - request.args, - ); - - // Check that onComplete was called with success - expect(onComplete).toHaveBeenCalledWith([ - expect.objectContaining({ - status: 'success', - request, - response: expect.objectContaining({ - resultDisplay: 'YOLO Formatted tool output', - responseParts: [ - { - functionResponse: { - id: 'yoloCall', - name: 'mockToolRequiresConfirmation', - response: { output: expectedOutput }, - }, - }, - ], - }), - }), - ]); + const [toolCalls] = result.current; + expect(toolCalls).toEqual([]); }); -}); -describe('useReactToolScheduler', () => { - let onComplete: Mock; - let capturedOnConfirmForTest: - | ((outcome: ToolConfirmationOutcome) => void | Promise) - | undefined; - - const advanceAndSettle = async () => { - await act(async () => { - await vi.advanceTimersByTimeAsync(0); - }); - }; - - const scheduleAndWaitForExecution = async ( - schedule: ( - req: ToolCallRequestInfo | ToolCallRequestInfo[], - signal: AbortSignal, - ) => Promise, - request: ToolCallRequestInfo | ToolCallRequestInfo[], - ) => { - await act(async () => { - await schedule(request, new AbortController().signal); - }); - - await advanceAndSettle(); - await advanceAndSettle(); - await advanceAndSettle(); - }; - - beforeEach(() => { - onComplete = vi.fn(); - capturedOnConfirmForTest = undefined; - - mockToolRegistry.getTool.mockClear(); - (mockTool.execute as Mock).mockClear(); - (mockTool.shouldConfirmExecute as Mock).mockClear(); - (mockToolWithLiveOutput.execute as Mock).mockClear(); - (mockToolWithLiveOutput.shouldConfirmExecute as Mock).mockClear(); - (mockToolRequiresConfirmation.execute as Mock).mockClear(); - (mockToolRequiresConfirmation.shouldConfirmExecute as Mock).mockClear(); - - mockOnUserConfirmForToolConfirmation = vi.fn(); - ( - mockToolRequiresConfirmation.shouldConfirmExecute as Mock - ).mockImplementation( - async (): Promise => - ({ - onConfirm: mockOnUserConfirmForToolConfirmation, - fileName: 'mockToolRequiresConfirmation.ts', - fileDiff: 'Mock tool requires confirmation', - type: 'edit', - title: 'Mock Tool Requires Confirmation', - }) as any, - ); - - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.clearAllTimers(); - vi.useRealTimers(); - }); - - const renderScheduler = (config: Config = mockConfig) => - renderHook(() => - useReactToolScheduler(onComplete, config, () => undefined), + it('updates tool calls when MessageBus emits TOOL_CALLS_UPDATE', () => { + const { result } = renderHook(() => + useToolScheduler( + vi.fn().mockResolvedValue(undefined), + mockConfig, + () => undefined, + ), ); - it('initial state should be empty', () => { - const { result } = renderScheduler(); - expect(result.current[0]).toEqual([]); - }); - - it('should schedule and execute a tool call successfully', async () => { - mockToolRegistry.getTool.mockReturnValue(mockTool); - (mockTool.execute as Mock).mockResolvedValue({ - llmContent: 'Tool output', - returnDisplay: 'Formatted tool output', - } as ToolResult); - (mockTool.shouldConfirmExecute as Mock).mockResolvedValue(null); - - const { result } = renderScheduler(); - const request: ToolCallRequestInfo = { - callId: 'call1', - name: 'mockTool', - args: { param: 'value' }, - } as any; - - let completedToolCalls: ToolCall[] = []; - onComplete.mockImplementation((calls) => { - completedToolCalls = calls; - }); - - await scheduleAndWaitForExecution(result.current[1], request); - - expect(mockTool.execute).toHaveBeenCalledWith(request.args); - expect(completedToolCalls).toHaveLength(1); - expect(completedToolCalls[0].status).toBe('success'); - expect(completedToolCalls[0].request).toBe(request); - - if ( - completedToolCalls[0].status === 'success' || - completedToolCalls[0].status === 'error' - ) { - expect(completedToolCalls[0].response).toMatchSnapshot(); - } - }); - - it('should clear previous tool calls when scheduling new ones', async () => { - mockToolRegistry.getTool.mockReturnValue(mockTool); - (mockTool.execute as Mock).mockImplementation(async () => { - await new Promise((r) => setTimeout(r, 10)); - return { - llmContent: 'Tool output', - returnDisplay: 'Formatted tool output', - }; - }); - - const { result } = renderScheduler(); - const schedule = result.current[1]; - const setToolCallsForDisplay = result.current[3]; + const mockToolCall = { + status: 'executing' as const, + request: { + callId: 'call-1', + name: 'test_tool', + args: {}, + isClientInitiated: false, + prompt_id: 'p1', + }, + tool: createMockTool(), + invocation: createMockInvocation(), + liveOutput: 'Loading...', + }; - // Manually set a tool call in the display. - const oldToolCall = { - request: { callId: 'oldCall' }, - status: 'success', - } as any; act(() => { - setToolCallsForDisplay([oldToolCall]); - }); - expect(result.current[0]).toEqual([oldToolCall]); - - const newRequest: ToolCallRequestInfo = { - callId: 'newCall', - name: 'mockTool', - args: {}, - } as any; - let schedulePromise: Promise; - await act(async () => { - schedulePromise = schedule(newRequest, new AbortController().signal); - }); - - await advanceAndSettle(); - - // After scheduling, the old call should be gone, - // and the new one should be in the display in its initial state. - expect(result.current[0].length).toBe(1); - expect(result.current[0][0].request.callId).toBe('newCall'); - expect(result.current[0][0].request.callId).not.toBe('oldCall'); - - // Let the new call finish. - await act(async () => { - await vi.advanceTimersByTimeAsync(20); - }); - - await act(async () => { - await schedulePromise; + void mockMessageBus.publish({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [mockToolCall], + schedulerId: ROOT_SCHEDULER_ID, + } as ToolCallsUpdateMessage); + }); + + const [toolCalls] = result.current; + expect(toolCalls).toHaveLength(1); + // Expect Core Object structure, not Display Object + expect(toolCalls[0]).toMatchObject({ + request: { callId: 'call-1', name: 'test_tool' }, + status: 'executing', // Core status + liveOutput: 'Loading...', + responseSubmittedToGemini: false, }); - - expect(onComplete).toHaveBeenCalled(); }); - it('should cancel all running tool calls', async () => { - mockToolRegistry.getTool.mockReturnValue(mockTool); - - let resolveExecute: (value: ToolResult) => void = () => {}; - const executePromise = new Promise((resolve) => { - resolveExecute = resolve; - }); - (mockTool.execute as Mock).mockReturnValue(executePromise); - (mockTool.shouldConfirmExecute as Mock).mockResolvedValue(null); - - const { result } = renderScheduler(); - const schedule = result.current[1]; - const cancelAllToolCalls = result.current[4]; - const request: ToolCallRequestInfo = { - callId: 'cancelCall', - name: 'mockTool', - args: {}, - } as any; - - let schedulePromise: Promise; - await act(async () => { - schedulePromise = schedule(request, new AbortController().signal); - }); - - await advanceAndSettle(); // validation - await advanceAndSettle(); // Process scheduling + it('injects onConfirm callback for awaiting_approval tools (Adapter Pattern)', async () => { + const { result } = renderHook(() => + useToolScheduler( + vi.fn().mockResolvedValue(undefined), + mockConfig, + () => undefined, + ), + ); - // At this point, the tool is 'executing' and waiting on the promise. - expect(result.current[0][0].status).toBe('executing'); + const mockToolCall = { + status: 'awaiting_approval' as const, + request: { + callId: 'call-1', + name: 'test_tool', + args: {}, + isClientInitiated: false, + prompt_id: 'p1', + }, + tool: createMockTool(), + invocation: createMockInvocation({ + getDescription: () => 'Confirming test tool', + }), + confirmationDetails: { type: 'info', title: 'Confirm', prompt: 'Sure?' }, + correlationId: 'corr-123', + }; - const cancelController = new AbortController(); act(() => { - cancelAllToolCalls(cancelController.signal); + void mockMessageBus.publish({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [mockToolCall], + schedulerId: ROOT_SCHEDULER_ID, + } as ToolCallsUpdateMessage); }); - await advanceAndSettle(); + const [toolCalls] = result.current; + const call = toolCalls[0]; + if (call.status !== 'awaiting_approval') { + throw new Error('Expected status to be awaiting_approval'); + } + const confirmationDetails = + call.confirmationDetails as ToolCallConfirmationDetails; - expect(onComplete).toHaveBeenCalledWith([ - expect.objectContaining({ - status: 'cancelled', - request, - }), - ]); + expect(confirmationDetails).toBeDefined(); + expect(typeof confirmationDetails.onConfirm).toBe('function'); - // Clean up the pending promise to avoid open handles. - await act(async () => { - resolveExecute({ llmContent: 'output', returnDisplay: 'display' }); - }); + // Test that onConfirm publishes to MessageBus + const publishSpy = vi.spyOn(mockMessageBus, 'publish'); + await confirmationDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce); - // Now await the schedule promise - await act(async () => { - await schedulePromise; + expect(publishSpy).toHaveBeenCalledWith({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-123', + confirmed: true, + requiresUserConfirmation: false, + outcome: ToolConfirmationOutcome.ProceedOnce, + payload: undefined, }); }); - it.each([ - { - desc: 'tool not found', - setup: () => { - mockToolRegistry.getTool.mockReturnValue(undefined); - }, - request: { - callId: 'call1', - name: 'nonexistentTool', - args: {}, - } as any, - expectedErrorContains: [ - 'Tool "nonexistentTool" not found in registry', - 'Did you mean one of:', - ], - }, - { - desc: 'error during shouldConfirmExecute', - setup: () => { - mockToolRegistry.getTool.mockReturnValue(mockTool); - const confirmError = new Error('Confirmation check failed'); - (mockTool.shouldConfirmExecute as Mock).mockRejectedValue(confirmError); - }, + it('injects onConfirm with payload (Inline Edit support)', async () => { + const { result } = renderHook(() => + useToolScheduler( + vi.fn().mockResolvedValue(undefined), + mockConfig, + () => undefined, + ), + ); + + const mockToolCall = { + status: 'awaiting_approval' as const, request: { - callId: 'call1', - name: 'mockTool', + callId: 'call-1', + name: 'test_tool', args: {}, - } as any, - expectedError: new Error('Confirmation check failed'), - }, - { - desc: 'error during execute', - setup: () => { - mockToolRegistry.getTool.mockReturnValue(mockTool); - (mockTool.shouldConfirmExecute as Mock).mockResolvedValue(null); - const execError = new Error('Execution failed'); - (mockTool.execute as Mock).mockRejectedValue(execError); + isClientInitiated: false, + prompt_id: 'p1', }, - request: { - callId: 'call1', - name: 'mockTool', - args: {}, - } as any, - expectedError: new Error('Execution failed'), - }, - ])( - 'should handle $desc', - async ({ setup, request, expectedErrorContains, expectedError }) => { - setup(); - const { result } = renderScheduler(); - - let completedToolCalls: ToolCall[] = []; - onComplete.mockImplementation((calls) => { - completedToolCalls = calls; - }); - - await scheduleAndWaitForExecution(result.current[1], request); - - expect(completedToolCalls).toHaveLength(1); - expect(completedToolCalls[0].status).toBe('error'); - expect(completedToolCalls[0].request).toBe(request); - - if (expectedErrorContains) { - expectedErrorContains.forEach((errorText) => { - expect( - (completedToolCalls[0] as any).response.error.message, - ).toContain(errorText); - }); - } - - if (expectedError) { - expect((completedToolCalls[0] as any).response.error.message).toBe( - expectedError.message, - ); - } - }, - ); - - it('should handle tool requiring confirmation - approved', async () => { - mockToolRegistry.getTool.mockReturnValue(mockToolRequiresConfirmation); - const config = createMockConfigOverride({ - isInteractive: () => true, - }); - const expectedOutput = 'Confirmed output'; - (mockToolRequiresConfirmation.execute as Mock).mockResolvedValue({ - llmContent: expectedOutput, - returnDisplay: 'Confirmed display', - } as ToolResult); - - const { result } = renderScheduler(config); - const schedule = result.current[1]; - const request: ToolCallRequestInfo = { - callId: 'callConfirm', - name: 'mockToolRequiresConfirmation', - args: { data: 'sensitive' }, - } as any; - - let schedulePromise: Promise; - await act(async () => { - schedulePromise = schedule(request, new AbortController().signal); - }); - await advanceAndSettle(); - - const waitingCall = result.current[0][0] as any; - expect(waitingCall.status).toBe('awaiting_approval'); - capturedOnConfirmForTest = waitingCall.confirmationDetails?.onConfirm; - expect(capturedOnConfirmForTest).toBeDefined(); + tool: createMockTool(), + invocation: createMockInvocation(), + confirmationDetails: { type: 'edit', title: 'Edit', filePath: 'test.ts' }, + correlationId: 'corr-edit', + }; - await act(async () => { - await capturedOnConfirmForTest?.(ToolConfirmationOutcome.ProceedOnce); + act(() => { + void mockMessageBus.publish({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [mockToolCall], + schedulerId: ROOT_SCHEDULER_ID, + } as ToolCallsUpdateMessage); }); - await advanceAndSettle(); - - // Now await the schedule promise as it should complete - await act(async () => { - await schedulePromise; - }); + const [toolCalls] = result.current; + const call = toolCalls[0]; + if (call.status !== 'awaiting_approval') { + throw new Error('Expected awaiting_approval'); + } + const confirmationDetails = + call.confirmationDetails as ToolCallConfirmationDetails; - expect(mockOnUserConfirmForToolConfirmation).toHaveBeenCalledWith( + const publishSpy = vi.spyOn(mockMessageBus, 'publish'); + const mockPayload = { newContent: 'updated code' }; + await confirmationDetails.onConfirm( ToolConfirmationOutcome.ProceedOnce, + mockPayload, ); - expect(mockToolRequiresConfirmation.execute).toHaveBeenCalled(); - const completedCalls = onComplete.mock.calls[0][0] as ToolCall[]; - expect(completedCalls[0].status).toBe('success'); - expect(completedCalls[0].request).toBe(request); - if ( - completedCalls[0].status === 'success' || - completedCalls[0].status === 'error' - ) { - expect(completedCalls[0].response).toMatchSnapshot(); - } - }); - - it('should handle tool requiring confirmation - cancelled by user', async () => { - mockToolRegistry.getTool.mockReturnValue(mockToolRequiresConfirmation); - const config = createMockConfigOverride({ - isInteractive: () => true, + expect(publishSpy).toHaveBeenCalledWith({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-edit', + confirmed: true, + requiresUserConfirmation: false, + outcome: ToolConfirmationOutcome.ProceedOnce, + payload: mockPayload, }); - const { result } = renderScheduler(config); - const schedule = result.current[1]; - const request: ToolCallRequestInfo = { - callId: 'callConfirmCancel', - name: 'mockToolRequiresConfirmation', - args: {}, - } as any; - - let schedulePromise: Promise; - await act(async () => { - schedulePromise = schedule(request, new AbortController().signal); - }); - await advanceAndSettle(); - - const waitingCall = result.current[0][0] as any; - expect(waitingCall.status).toBe('awaiting_approval'); - capturedOnConfirmForTest = waitingCall.confirmationDetails?.onConfirm; - expect(capturedOnConfirmForTest).toBeDefined(); - - await act(async () => { - await capturedOnConfirmForTest?.(ToolConfirmationOutcome.Cancel); - }); - - await advanceAndSettle(); - - // Now await the schedule promise - await act(async () => { - await schedulePromise; - }); - - expect(mockOnUserConfirmForToolConfirmation).toHaveBeenCalledWith( - ToolConfirmationOutcome.Cancel, - ); - - const completedCalls = onComplete.mock.calls[0][0] as ToolCall[]; - expect(completedCalls[0].status).toBe('cancelled'); - expect(completedCalls[0].request).toBe(request); - if ( - completedCalls[0].status === 'success' || - completedCalls[0].status === 'error' || - completedCalls[0].status === 'cancelled' - ) { - expect(completedCalls[0].response).toMatchSnapshot(); - } }); - it('should handle live output updates', async () => { - mockToolRegistry.getTool.mockReturnValue(mockToolWithLiveOutput); - let liveUpdateFn: ((output: string) => void) | undefined; - let resolveExecutePromise: (value: ToolResult) => void; - const executePromise = new Promise((resolve) => { - resolveExecutePromise = resolve; - }); - - (mockToolWithLiveOutput.execute as Mock).mockImplementation( - async ( - _args: Record, - _signal: AbortSignal, - updateFn: ((output: string) => void) | undefined, - ) => { - liveUpdateFn = updateFn; - return executePromise; - }, - ); - (mockToolWithLiveOutput.shouldConfirmExecute as Mock).mockResolvedValue( - null, + it('preserves responseSubmittedToGemini flag across updates', () => { + const { result } = renderHook(() => + useToolScheduler( + vi.fn().mockResolvedValue(undefined), + mockConfig, + () => undefined, + ), ); - const { result } = renderScheduler(); - const request: ToolCallRequestInfo = { - callId: 'liveCall', - name: 'mockToolWithLiveOutput', - args: {}, - } as any; + const mockToolCall = { + status: 'success' as const, + request: { + callId: 'call-1', + name: 'test', + args: {}, + isClientInitiated: false, + prompt_id: 'p1', + }, + tool: createMockTool(), + invocation: createMockInvocation(), + response: { + callId: 'call-1', + resultDisplay: 'OK', + responseParts: [], + error: undefined, + errorType: undefined, + }, + }; - let schedulePromise: Promise; - await act(async () => { - schedulePromise = result.current[1]( - request, - new AbortController().signal, - ); + // 1. Initial success + act(() => { + void mockMessageBus.publish({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [mockToolCall], + schedulerId: ROOT_SCHEDULER_ID, + } as ToolCallsUpdateMessage); }); - await advanceAndSettle(); - expect(liveUpdateFn).toBeDefined(); - expect(result.current[0][0].status).toBe('executing'); - - await act(async () => { - liveUpdateFn?.('Live output 1'); + // 2. Mark as submitted + act(() => { + const [, , markAsSubmitted] = result.current; + markAsSubmitted(['call-1']); }); - await advanceAndSettle(); - await act(async () => { - liveUpdateFn?.('Live output 2'); - }); - await advanceAndSettle(); + expect(result.current[0][0].responseSubmittedToGemini).toBe(true); + // 3. Receive another update (should preserve the true flag) act(() => { - resolveExecutePromise({ - llmContent: 'Final output', - returnDisplay: 'Final display', - } as ToolResult); - }); - await advanceAndSettle(); - - // Now await schedule - await act(async () => { - await schedulePromise; + void mockMessageBus.publish({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [mockToolCall], + schedulerId: ROOT_SCHEDULER_ID, + } as ToolCallsUpdateMessage); }); - const completedCalls = onComplete.mock.calls[0][0] as ToolCall[]; - expect(completedCalls[0].status).toBe('success'); - expect(completedCalls[0].request).toBe(request); - if ( - completedCalls[0].status === 'success' || - completedCalls[0].status === 'error' - ) { - expect(completedCalls[0].response).toMatchSnapshot(); - } - expect(result.current[0]).toEqual([]); + expect(result.current[0][0].responseSubmittedToGemini).toBe(true); }); - it('should schedule and execute multiple tool calls', async () => { - const tool1 = new MockTool({ - name: 'tool1', - displayName: 'Tool 1', - execute: vi.fn().mockResolvedValue({ - llmContent: 'Output 1', - returnDisplay: 'Display 1', - } as ToolResult), - }); - - const tool2 = new MockTool({ - name: 'tool2', - displayName: 'Tool 2', - execute: vi.fn().mockResolvedValue({ - llmContent: 'Output 2', - returnDisplay: 'Display 2', - } as ToolResult), - }); - - mockToolRegistry.getTool.mockImplementation((name) => { - if (name === 'tool1') return tool1; - if (name === 'tool2') return tool2; - return undefined; - }); - - const { result } = renderScheduler(); - const schedule = result.current[1]; - const requests: ToolCallRequestInfo[] = [ - { callId: 'multi1', name: 'tool1', args: { p: 1 } } as any, - { callId: 'multi2', name: 'tool2', args: { p: 2 } } as any, - ]; - - await act(async () => { - await schedule(requests, new AbortController().signal); - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(0); - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(0); - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(0); - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(0); - }); - - expect(onComplete).toHaveBeenCalledTimes(1); - const completedCalls = onComplete.mock.calls[0][0] as ToolCall[]; - expect(completedCalls.length).toBe(2); - - const call1Result = completedCalls.find( - (c) => c.request.callId === 'multi1', - ); - const call2Result = completedCalls.find( - (c) => c.request.callId === 'multi2', + it('updates lastToolOutputTime when tools are executing', () => { + vi.useFakeTimers(); + const { result } = renderHook(() => + useToolScheduler( + vi.fn().mockResolvedValue(undefined), + mockConfig, + () => undefined, + ), ); - expect(call1Result).toMatchObject({ - status: 'success', - request: requests[0], - response: expect.objectContaining({ - resultDisplay: 'Display 1', - responseParts: [ - { - functionResponse: { - id: 'multi1', - name: 'tool1', - response: { output: 'Output 1' }, - }, - }, - ], - }), - }); - expect(call2Result).toMatchObject({ - status: 'success', - request: requests[1], - response: expect.objectContaining({ - resultDisplay: 'Display 2', - responseParts: [ + const startTime = Date.now(); + vi.advanceTimersByTime(1000); + + act(() => { + void mockMessageBus.publish({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [ { - functionResponse: { - id: 'multi2', - name: 'tool2', - response: { output: 'Output 2' }, + status: 'executing' as const, + request: { + callId: 'call-1', + name: 'test', + args: {}, + isClientInitiated: false, + prompt_id: 'p1', }, + tool: createMockTool(), + invocation: createMockInvocation(), }, ], - }), + schedulerId: ROOT_SCHEDULER_ID, + } as ToolCallsUpdateMessage); }); - expect(completedCalls).toHaveLength(2); - expect(completedCalls.every((t) => t.status === 'success')).toBe(true); + const [, , , , , lastOutputTime] = result.current; + expect(lastOutputTime).toBeGreaterThan(startTime); + vi.useRealTimers(); }); - it('should queue if scheduling while already running', async () => { - mockToolRegistry.getTool.mockReturnValue(mockTool); - const longExecutePromise = new Promise((resolve) => - setTimeout( - () => - resolve({ - llmContent: 'done', - returnDisplay: 'done display', - }), - 50, + it('delegates cancelAll to the Core Scheduler', () => { + const { result } = renderHook(() => + useToolScheduler( + vi.fn().mockResolvedValue(undefined), + mockConfig, + () => undefined, ), ); - (mockTool.execute as Mock).mockReturnValue(longExecutePromise); - (mockTool.shouldConfirmExecute as Mock).mockResolvedValue(null); - const { result } = renderScheduler(); - const schedule = result.current[1]; - const request1: ToolCallRequestInfo = { - callId: 'run1', - name: 'mockTool', - args: {}, - } as any; - const request2: ToolCallRequestInfo = { - callId: 'run2', - name: 'mockTool', - args: {}, - } as any; + const [, , , , cancelAll] = result.current; + const signal = new AbortController().signal; - let schedulePromise1: Promise; - let schedulePromise2: Promise; + // We need to find the mock instance of Scheduler + // Since we used vi.mock at top level, we can get it from vi.mocked(Scheduler) + const schedulerInstance = vi.mocked(Scheduler).mock.results[0].value; - await act(async () => { - schedulePromise1 = schedule(request1, new AbortController().signal); - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(0); - }); + cancelAll(signal); - await act(async () => { - schedulePromise2 = schedule(request2, new AbortController().signal); - }); + expect(schedulerInstance.cancelAll).toHaveBeenCalled(); + }); - await act(async () => { - await vi.advanceTimersByTimeAsync(50); - await vi.advanceTimersByTimeAsync(0); - }); + it('resolves the schedule promise when scheduler resolves', async () => { + const onComplete = vi.fn().mockResolvedValue(undefined); - // Wait for first to complete - await act(async () => { - await schedulePromise1; - }); + const completedToolCall = { + status: 'success' as const, + request: { + callId: 'call-1', + name: 'test', + args: {}, + isClientInitiated: false, + prompt_id: 'p1', + }, + tool: createMockTool(), + invocation: createMockInvocation(), + response: { + callId: 'call-1', + responseParts: [], + resultDisplay: 'Success', + error: undefined, + errorType: undefined, + }, + }; - expect(onComplete).toHaveBeenCalledWith([ - expect.objectContaining({ - status: 'success', - request: request1, - response: expect.objectContaining({ resultDisplay: 'done display' }), - }), - ]); + // Mock the specific return value for this test + const { Scheduler } = await import('@google/gemini-cli-core'); + vi.mocked(Scheduler).mockImplementation( + () => + ({ + schedule: vi.fn().mockResolvedValue([completedToolCall]), + cancelAll: vi.fn(), + }) as unknown as Scheduler, + ); - await act(async () => { - await vi.advanceTimersByTimeAsync(50); - await vi.advanceTimersByTimeAsync(0); - }); + const { result } = renderHook(() => + useToolScheduler(onComplete, mockConfig, () => undefined), + ); - // Wait for second to complete + const [, schedule] = result.current; + const signal = new AbortController().signal; + + let completedResult: CompletedToolCall[] = []; await act(async () => { - await schedulePromise2; + completedResult = await schedule( + { + callId: 'call-1', + name: 'test', + args: {}, + isClientInitiated: false, + prompt_id: 'p1', + }, + signal, + ); }); - expect(onComplete).toHaveBeenCalledWith([ - expect.objectContaining({ - status: 'success', - request: request2, - response: expect.objectContaining({ resultDisplay: 'done display' }), - }), - ]); - const toolCalls = result.current[0]; - expect(toolCalls).toHaveLength(0); - }); -}); - -describe('mapToDisplay', () => { - const baseRequest: ToolCallRequestInfo = { - callId: 'testCallId', - name: 'testTool', - args: { foo: 'bar' }, - } as any; - - const baseTool = new MockTool({ - name: 'testTool', - displayName: 'Test Tool Display', - execute: vi.fn(), - shouldConfirmExecute: vi.fn(), + expect(completedResult).toEqual([completedToolCall]); + expect(onComplete).toHaveBeenCalledWith([completedToolCall]); }); - const baseResponse: ToolCallResponseInfo = { - callId: 'testCallId', - responseParts: [ - { - functionResponse: { - name: 'testTool', - id: 'testCallId', - response: { output: 'Test output' }, - } as FunctionResponse, - } as PartUnion, - ], - resultDisplay: 'Test display output', - error: undefined, - } as any; - - // Define a more specific type for extraProps for these tests - // This helps ensure that tool and confirmationDetails are only accessed when they are expected to exist. - type MapToDisplayExtraProps = - | { - tool?: AnyDeclarativeTool; - invocation?: AnyToolInvocation; - liveOutput?: string; - response?: ToolCallResponseInfo; - confirmationDetails?: ToolCallConfirmationDetails; - } - | { - tool: AnyDeclarativeTool; - invocation?: AnyToolInvocation; - response?: ToolCallResponseInfo; - confirmationDetails?: ToolCallConfirmationDetails; - } - | { - response: ToolCallResponseInfo; - tool?: undefined; - confirmationDetails?: ToolCallConfirmationDetails; - } - | { - confirmationDetails: ToolCallConfirmationDetails; - tool?: AnyDeclarativeTool; - invocation?: AnyToolInvocation; - response?: ToolCallResponseInfo; - }; + it('setToolCallsForDisplay re-groups tools by schedulerId (Multi-Scheduler support)', () => { + const { result } = renderHook(() => + useToolScheduler( + vi.fn().mockResolvedValue(undefined), + mockConfig, + () => undefined, + ), + ); - const baseInvocation = baseTool.build(baseRequest.args); - const testCases: Array<{ - name: string; - status: ToolCallStatusType; - extraProps?: MapToDisplayExtraProps; - expectedStatus: ToolCallStatus; - expectedResultDisplay?: string; - expectedName?: string; - expectedDescription?: string; - }> = [ - { - name: 'validating', - status: 'validating', - extraProps: { tool: baseTool, invocation: baseInvocation }, - expectedStatus: ToolCallStatus.Pending, - expectedName: baseTool.displayName, - expectedDescription: baseInvocation.getDescription(), - }, - { - name: 'awaiting_approval', - status: 'awaiting_approval', - extraProps: { - tool: baseTool, - invocation: baseInvocation, - confirmationDetails: { - onConfirm: vi.fn(), - type: 'edit', - title: 'Test Tool Display', - serverName: 'testTool', - toolName: 'testTool', - toolDisplayName: 'Test Tool Display', - filePath: 'mock', - fileName: 'test.ts', - fileDiff: 'Test diff', - originalContent: 'Original content', - newContent: 'New content', - } as ToolCallConfirmationDetails, - }, - expectedStatus: ToolCallStatus.Confirming, - expectedName: baseTool.displayName, - expectedDescription: baseInvocation.getDescription(), - }, - { - name: 'scheduled', - status: 'scheduled', - extraProps: { tool: baseTool, invocation: baseInvocation }, - expectedStatus: ToolCallStatus.Pending, - expectedName: baseTool.displayName, - expectedDescription: baseInvocation.getDescription(), - }, - { - name: 'executing no live output', - status: 'executing', - extraProps: { tool: baseTool, invocation: baseInvocation }, - expectedStatus: ToolCallStatus.Executing, - expectedName: baseTool.displayName, - expectedDescription: baseInvocation.getDescription(), - }, - { - name: 'executing with live output', - status: 'executing', - extraProps: { - tool: baseTool, - invocation: baseInvocation, - liveOutput: 'Live test output', - }, - expectedStatus: ToolCallStatus.Executing, - expectedResultDisplay: 'Live test output', - expectedName: baseTool.displayName, - expectedDescription: baseInvocation.getDescription(), - }, - { - name: 'success', - status: 'success', - extraProps: { - tool: baseTool, - invocation: baseInvocation, - response: baseResponse, - }, - expectedStatus: ToolCallStatus.Success, - expectedResultDisplay: baseResponse.resultDisplay as any, - expectedName: baseTool.displayName, - expectedDescription: baseInvocation.getDescription(), - }, - { - name: 'error tool not found', - status: 'error', - extraProps: { - response: { - ...baseResponse, - error: new Error('Test error tool not found'), - resultDisplay: 'Error display tool not found', - }, - }, - expectedStatus: ToolCallStatus.Error, - expectedResultDisplay: 'Error display tool not found', - expectedName: baseRequest.name, - expectedDescription: JSON.stringify(baseRequest.args), - }, - { - name: 'error tool execution failed', - status: 'error', - extraProps: { - tool: baseTool, - response: { - ...baseResponse, - error: new Error('Tool execution failed'), - resultDisplay: 'Execution failed display', - }, + const callRoot = { + status: 'success' as const, + request: { + callId: 'call-root', + name: 'test', + args: {}, + isClientInitiated: false, + prompt_id: 'p1', }, - expectedStatus: ToolCallStatus.Error, - expectedResultDisplay: 'Execution failed display', - expectedName: baseTool.displayName, // Changed from baseTool.name - expectedDescription: JSON.stringify(baseRequest.args), - }, - { - name: 'cancelled', - status: 'cancelled', - extraProps: { - tool: baseTool, - invocation: baseInvocation, - response: { - ...baseResponse, - resultDisplay: 'Cancelled display', - }, + tool: createMockTool(), + invocation: createMockInvocation(), + response: { + callId: 'call-root', + responseParts: [], + resultDisplay: 'OK', + error: undefined, + errorType: undefined, }, - expectedStatus: ToolCallStatus.Canceled, - expectedResultDisplay: 'Cancelled display', - expectedName: baseTool.displayName, - expectedDescription: baseInvocation.getDescription(), - }, - ]; - - testCases.forEach( - ({ - name: testName, - status, - extraProps, - expectedStatus, - expectedResultDisplay, - expectedName, - expectedDescription, - }) => { - it(`should map ToolCall with status '${status}' (${testName}) correctly`, () => { - const toolCall: ToolCall = { - request: baseRequest, - status, - ...(extraProps || {}), - } as ToolCall; + schedulerId: ROOT_SCHEDULER_ID, + }; - const display = mapToDisplay(toolCall); - expect(display.type).toBe('tool_group'); - expect(display.tools.length).toBe(1); - const toolDisplay = display.tools[0]; + const callSub = { + ...callRoot, + request: { ...callRoot.request, callId: 'call-sub' }, + schedulerId: 'subagent-1', + }; - expect(toolDisplay.callId).toBe(baseRequest.callId); - expect(toolDisplay.status).toBe(expectedStatus); - expect(toolDisplay.resultDisplay).toBe(expectedResultDisplay); + // 1. Populate state with multiple schedulers + act(() => { + void mockMessageBus.publish({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [callRoot], + schedulerId: ROOT_SCHEDULER_ID, + } as ToolCallsUpdateMessage); + + void mockMessageBus.publish({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [callSub], + schedulerId: 'subagent-1', + } as ToolCallsUpdateMessage); + }); + + let [toolCalls] = result.current; + expect(toolCalls).toHaveLength(2); + expect( + toolCalls.find((t) => t.request.callId === 'call-root')?.schedulerId, + ).toBe(ROOT_SCHEDULER_ID); + expect( + toolCalls.find((t) => t.request.callId === 'call-sub')?.schedulerId, + ).toBe('subagent-1'); + + // 2. Call setToolCallsForDisplay (e.g., simulate a manual update or clear) + act(() => { + const [, , , setToolCalls] = result.current; + setToolCalls((prev) => + prev.map((t) => ({ ...t, responseSubmittedToGemini: true })), + ); + }); - expect(toolDisplay.name).toBe(expectedName); - expect(toolDisplay.description).toBe(expectedDescription); + // 3. Verify that tools are still present and maintain their scheduler IDs + // The internal map should have been re-grouped. + [toolCalls] = result.current; + expect(toolCalls).toHaveLength(2); + expect(toolCalls.every((t) => t.responseSubmittedToGemini)).toBe(true); - expect(toolDisplay.renderOutputAsMarkdown).toBe( - extraProps?.tool?.isOutputMarkdown ?? false, - ); - if (status === 'awaiting_approval') { - expect(toolDisplay.confirmationDetails).toBe( - extraProps!.confirmationDetails, - ); - } else { - expect(toolDisplay.confirmationDetails).toBeUndefined(); - } - }); - }, - ); + const updatedRoot = toolCalls.find((t) => t.request.callId === 'call-root'); + const updatedSub = toolCalls.find((t) => t.request.callId === 'call-sub'); - it('should map an array of ToolCalls correctly', () => { - const toolCall1: ToolCall = { - request: { ...baseRequest, callId: 'call1' }, - status: 'success', - tool: baseTool, - invocation: baseTool.build(baseRequest.args), - response: { ...baseResponse, callId: 'call1' }, - } as ToolCall; - const toolForCall2 = new MockTool({ - name: baseTool.name, - displayName: baseTool.displayName, - isOutputMarkdown: true, - execute: vi.fn(), - shouldConfirmExecute: vi.fn(), - }); - const toolCall2: ToolCall = { - request: { ...baseRequest, callId: 'call2' }, - status: 'executing', - tool: toolForCall2, - invocation: toolForCall2.build(baseRequest.args), - liveOutput: 'markdown output', - } as ToolCall; + expect(updatedRoot?.schedulerId).toBe(ROOT_SCHEDULER_ID); + expect(updatedSub?.schedulerId).toBe('subagent-1'); - const display = mapToDisplay([toolCall1, toolCall2]); - expect(display.tools.length).toBe(2); - expect(display.tools[0].callId).toBe('call1'); - expect(display.tools[0].status).toBe(ToolCallStatus.Success); - expect(display.tools[0].renderOutputAsMarkdown).toBe(false); - expect(display.tools[1].callId).toBe('call2'); - expect(display.tools[1].status).toBe(ToolCallStatus.Executing); - expect(display.tools[1].resultDisplay).toBe('markdown output'); - expect(display.tools[1].renderOutputAsMarkdown).toBe(true); + // 4. Verify that a subsequent update to ONE scheduler doesn't wipe the other + act(() => { + void mockMessageBus.publish({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [{ ...callRoot, status: 'executing' }], + schedulerId: ROOT_SCHEDULER_ID, + } as ToolCallsUpdateMessage); + }); + + [toolCalls] = result.current; + expect(toolCalls).toHaveLength(2); + expect( + toolCalls.find((t) => t.request.callId === 'call-root')?.status, + ).toBe('executing'); + expect( + toolCalls.find((t) => t.request.callId === 'call-sub')?.schedulerId, + ).toBe('subagent-1'); }); }); diff --git a/packages/cli/src/ui/hooks/useToolScheduler.ts b/packages/cli/src/ui/hooks/useToolScheduler.ts index b6835565e7d..b50ed1b7178 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.ts @@ -4,67 +4,273 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - Config, - EditorType, - CompletedToolCall, - ToolCallRequestInfo, -} from '@google/gemini-cli-core'; -import { - type TrackedScheduledToolCall, - type TrackedValidatingToolCall, - type TrackedWaitingToolCall, - type TrackedExecutingToolCall, - type TrackedCompletedToolCall, - type TrackedCancelledToolCall, - type MarkToolsAsSubmittedFn, - type CancelAllFn, -} from './useReactToolScheduler.js'; import { - useToolExecutionScheduler, - type TrackedToolCall, -} from './useToolExecutionScheduler.js'; - -// Re-export specific state types from Legacy, as the structures are compatible -// and useGeminiStream relies on them for narrowing. -export type { - TrackedToolCall, - TrackedScheduledToolCall, - TrackedValidatingToolCall, - TrackedWaitingToolCall, - TrackedExecutingToolCall, - TrackedCompletedToolCall, - TrackedCancelledToolCall, - MarkToolsAsSubmittedFn, - CancelAllFn, -}; + type Config, + type MessageBus, + type ToolCallRequestInfo, + type ToolCall, + type CompletedToolCall, + type ToolConfirmationPayload, + MessageBusType, + ToolConfirmationOutcome, + Scheduler, + type EditorType, + type ToolCallsUpdateMessage, + ROOT_SCHEDULER_ID, +} from '@google/gemini-cli-core'; +import { useCallback, useState, useMemo, useEffect, useRef } from 'react'; -// Unified Schedule function (Promise | Promise) +// Re-exporting types compatible with legacy hook expectations export type ScheduleFn = ( request: ToolCallRequestInfo | ToolCallRequestInfo[], signal: AbortSignal, -) => Promise; +) => Promise; + +export type MarkToolsAsSubmittedFn = (callIds: string[]) => void; +export type CancelAllFn = (signal: AbortSignal) => void; -export type UseToolSchedulerReturn = [ +/** + * The shape expected by useGeminiStream. + * It matches the Core ToolCall structure + the UI metadata flag. + */ +export type TrackedToolCall = ToolCall & { + responseSubmittedToGemini?: boolean; +}; + +// Narrowed types for specific statuses (used by useGeminiStream) +export type TrackedScheduledToolCall = Extract< + TrackedToolCall, + { status: 'scheduled' } +>; +export type TrackedValidatingToolCall = Extract< + TrackedToolCall, + { status: 'validating' } +>; +export type TrackedWaitingToolCall = Extract< + TrackedToolCall, + { status: 'awaiting_approval' } +>; +export type TrackedExecutingToolCall = Extract< + TrackedToolCall, + { status: 'executing' } +>; +export type TrackedCompletedToolCall = Extract< + TrackedToolCall, + { status: 'success' | 'error' } +>; +export type TrackedCancelledToolCall = Extract< + TrackedToolCall, + { status: 'cancelled' } +>; + +/** + * Modern tool scheduler hook using the event-driven Core Scheduler. + */ +export function useToolScheduler( + onComplete: (tools: CompletedToolCall[]) => Promise, + config: Config, + getPreferredEditor: () => EditorType | undefined, +): [ TrackedToolCall[], ScheduleFn, MarkToolsAsSubmittedFn, React.Dispatch>, CancelAllFn, number, -]; +] { + // State stores tool calls organized by their originating schedulerId + const [toolCallsMap, setToolCallsMap] = useState< + Record + >({}); + const [lastToolOutputTime, setLastToolOutputTime] = useState(0); + + const messageBus = useMemo(() => config.getMessageBus(), [config]); + + const onCompleteRef = useRef(onComplete); + useEffect(() => { + onCompleteRef.current = onComplete; + }, [onComplete]); + + const getPreferredEditorRef = useRef(getPreferredEditor); + useEffect(() => { + getPreferredEditorRef.current = getPreferredEditor; + }, [getPreferredEditor]); + + const scheduler = useMemo( + () => + new Scheduler({ + config, + messageBus, + getPreferredEditor: () => getPreferredEditorRef.current(), + schedulerId: ROOT_SCHEDULER_ID, + }), + [config, messageBus], + ); + + const internalAdaptToolCalls = useCallback( + (coreCalls: ToolCall[], prevTracked: TrackedToolCall[]) => + adaptToolCalls(coreCalls, prevTracked, messageBus), + [messageBus], + ); + + useEffect(() => { + const handler = (event: ToolCallsUpdateMessage) => { + // Update output timer for UI spinners (Side Effect) + if (event.toolCalls.some((tc) => tc.status === 'executing')) { + setLastToolOutputTime(Date.now()); + } + + setToolCallsMap((prev) => { + const adapted = internalAdaptToolCalls( + event.toolCalls, + prev[event.schedulerId] ?? [], + ); + + return { + ...prev, + [event.schedulerId]: adapted, + }; + }); + }; + + messageBus.subscribe(MessageBusType.TOOL_CALLS_UPDATE, handler); + return () => { + messageBus.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, handler); + }; + }, [messageBus, internalAdaptToolCalls]); + + const schedule: ScheduleFn = useCallback( + async (request, signal) => { + // Clear state for new run + setToolCallsMap({}); + + // 1. Await Core Scheduler directly + const results = await scheduler.schedule(request, signal); + + // 2. Trigger legacy reinjection logic (useGeminiStream loop) + // Since this hook instance owns the "root" scheduler, we always trigger + // onComplete when it finishes its batch. + await onCompleteRef.current(results); + + return results; + }, + [scheduler], + ); + + const cancelAll: CancelAllFn = useCallback( + (_signal) => { + scheduler.cancelAll(); + }, + [scheduler], + ); + + const markToolsAsSubmitted: MarkToolsAsSubmittedFn = useCallback( + (callIdsToMark: string[]) => { + setToolCallsMap((prevMap) => { + const nextMap = { ...prevMap }; + for (const [sid, calls] of Object.entries(nextMap)) { + nextMap[sid] = calls.map((tc) => + callIdsToMark.includes(tc.request.callId) + ? { ...tc, responseSubmittedToGemini: true } + : tc, + ); + } + return nextMap; + }); + }, + [], + ); + + // Flatten the map for the UI components that expect a single list of tools. + const toolCalls = useMemo( + () => Object.values(toolCallsMap).flat(), + [toolCallsMap], + ); + + // Provide a setter that maintains compatibility with legacy []. + const setToolCallsForDisplay = useCallback( + (action: React.SetStateAction) => { + setToolCallsMap((prev) => { + const currentFlattened = Object.values(prev).flat(); + const nextFlattened = + typeof action === 'function' ? action(currentFlattened) : action; + + if (nextFlattened.length === 0) { + return {}; + } + + // Re-group by schedulerId to preserve multi-scheduler state + const nextMap: Record = {}; + for (const call of nextFlattened) { + // All tool calls should have a schedulerId from the core. + // Default to ROOT_SCHEDULER_ID as a safeguard. + const sid = call.schedulerId ?? ROOT_SCHEDULER_ID; + if (!nextMap[sid]) { + nextMap[sid] = []; + } + nextMap[sid].push(call); + } + return nextMap; + }); + }, + [], + ); + + return [ + toolCalls, + schedule, + markToolsAsSubmitted, + setToolCallsForDisplay, + cancelAll, + lastToolOutputTime, + ]; +} /** - * Hook that uses the Event-Driven scheduler for tool execution. + * ADAPTER: Merges UI metadata (submitted flag) and injects legacy callbacks. */ -export function useToolScheduler( - onComplete: (tools: CompletedToolCall[]) => Promise, - config: Config, - getPreferredEditor: () => EditorType | undefined, -): UseToolSchedulerReturn { - return useToolExecutionScheduler( - onComplete, - config, - getPreferredEditor, - ) as UseToolSchedulerReturn; +function adaptToolCalls( + coreCalls: ToolCall[], + prevTracked: TrackedToolCall[], + messageBus: MessageBus, +): TrackedToolCall[] { + const prevMap = new Map(prevTracked.map((t) => [t.request.callId, t])); + + return coreCalls.map((coreCall): TrackedToolCall => { + const prev = prevMap.get(coreCall.request.callId); + const responseSubmittedToGemini = prev?.responseSubmittedToGemini ?? false; + + // Inject onConfirm adapter for tools awaiting approval. + // The Core provides data-only (serializable) confirmationDetails. We must + // inject the legacy callback function that proxies responses back to the + // MessageBus. + if (coreCall.status === 'awaiting_approval' && coreCall.correlationId) { + const correlationId = coreCall.correlationId; + return { + ...coreCall, + confirmationDetails: { + ...coreCall.confirmationDetails, + onConfirm: async ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => { + await messageBus.publish({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId, + confirmed: outcome !== ToolConfirmationOutcome.Cancel, + requiresUserConfirmation: false, + outcome, + payload, + }); + }, + }, + responseSubmittedToGemini, + }; + } + + return { + ...coreCall, + responseSubmittedToGemini, + }; + }); } diff --git a/packages/cli/src/ui/hooks/useTurnActivityMonitor.test.ts b/packages/cli/src/ui/hooks/useTurnActivityMonitor.test.ts index 9ac44c3ebcd..f77ab7504dc 100644 --- a/packages/cli/src/ui/hooks/useTurnActivityMonitor.test.ts +++ b/packages/cli/src/ui/hooks/useTurnActivityMonitor.test.ts @@ -9,7 +9,7 @@ import { renderHook } from '../../test-utils/render.js'; import { useTurnActivityMonitor } from './useTurnActivityMonitor.js'; import { StreamingState } from '../types.js'; import { hasRedirection } from '@google/gemini-cli-core'; -import { type TrackedToolCall } from './useReactToolScheduler.js'; +import { type TrackedToolCall } from './useToolScheduler.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal>(); diff --git a/packages/cli/src/ui/hooks/useTurnActivityMonitor.ts b/packages/cli/src/ui/hooks/useTurnActivityMonitor.ts index cd6ee7ee8a0..8cd7883007d 100644 --- a/packages/cli/src/ui/hooks/useTurnActivityMonitor.ts +++ b/packages/cli/src/ui/hooks/useTurnActivityMonitor.ts @@ -7,7 +7,7 @@ import { useState, useEffect, useRef, useMemo } from 'react'; import { StreamingState } from '../types.js'; import { hasRedirection } from '@google/gemini-cli-core'; -import { type TrackedToolCall } from './useReactToolScheduler.js'; +import { type TrackedToolCall } from './useToolScheduler.js'; export interface TurnActivityStatus { operationStartTime: number; diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.ts b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.ts deleted file mode 100644 index 11fb6d56eba..00000000000 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { getPlainTextLength } from './InlineMarkdownRenderer.js'; -import { describe, it, expect } from 'vitest'; - -describe('getPlainTextLength', () => { - it.each([ - ['**Primary Go', 12], - ['*Primary Go', 11], - ['**Primary Go**', 10], - ['*Primary Go*', 10], - ['**', 2], - ['*', 1], - ['compile-time**', 14], - ])( - 'should measure markdown text length correctly for "%s"', - (input, expected) => { - expect(getPlainTextLength(input)).toBe(expected); - }, - ); -}); diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index 8d4c6a7da60..0418582919e 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -7,7 +7,6 @@ import React from 'react'; import { Text } from 'ink'; import { theme } from '../semantic-colors.js'; -import stringWidth from 'string-width'; import { debugLogger } from '@google/gemini-cli-core'; // Constants for Markdown parsing @@ -171,19 +170,3 @@ const RenderInlineInternal: React.FC = ({ }; export const RenderInline = React.memo(RenderInlineInternal); - -/** - * Utility function to get the plain text length of a string with markdown formatting - * This is useful for calculating column widths in tables - */ -export const getPlainTextLength = (text: string): number => { - const cleanText = text - .replace(/\*\*(.*?)\*\*/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - .replace(/_(.*?)_/g, '$1') - .replace(/~~(.*?)~~/g, '$1') - .replace(/`(.*?)`/g, '$1') - .replace(/(.*?)<\/u>/g, '$1') - .replace(/.*\[(.*?)\]\(.*\)/g, '$1'); - return stringWidth(cleanText); -}; diff --git a/packages/cli/src/utils/devtoolsService.test.ts b/packages/cli/src/utils/devtoolsService.test.ts index 922d4d1483e..cb3b907d970 100644 --- a/packages/cli/src/utils/devtoolsService.test.ts +++ b/packages/cli/src/utils/devtoolsService.test.ts @@ -437,7 +437,19 @@ describe('devtoolsService', () => { }); describe('toggleDevToolsPanel', () => { - it('calls toggle when browser opens successfully', async () => { + it('calls toggle (to close) when already open', async () => { + const config = createMockConfig(); + const toggle = vi.fn(); + const setOpen = vi.fn(); + + const promise = toggleDevToolsPanel(config, true, toggle, setOpen); + await promise; + + expect(toggle).toHaveBeenCalledTimes(1); + expect(setOpen).not.toHaveBeenCalled(); + }); + + it('does NOT call toggle or setOpen when browser opens successfully', async () => { const config = createMockConfig(); const toggle = vi.fn(); const setOpen = vi.fn(); @@ -447,18 +459,18 @@ describe('devtoolsService', () => { mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); mockDevToolsInstance.getPort.mockReturnValue(25417); - const promise = toggleDevToolsPanel(config, toggle, setOpen); + const promise = toggleDevToolsPanel(config, false, toggle, setOpen); await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); MockWebSocket.instances[0].simulateError(); await promise; - expect(toggle).toHaveBeenCalledTimes(1); + expect(toggle).not.toHaveBeenCalled(); expect(setOpen).not.toHaveBeenCalled(); }); - it('calls toggle when browser fails to open', async () => { + it('calls setOpen when browser fails to open', async () => { const config = createMockConfig(); const toggle = vi.fn(); const setOpen = vi.fn(); @@ -468,18 +480,18 @@ describe('devtoolsService', () => { mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); mockDevToolsInstance.getPort.mockReturnValue(25417); - const promise = toggleDevToolsPanel(config, toggle, setOpen); + const promise = toggleDevToolsPanel(config, false, toggle, setOpen); await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); MockWebSocket.instances[0].simulateError(); await promise; - expect(toggle).toHaveBeenCalledTimes(1); - expect(setOpen).not.toHaveBeenCalled(); + expect(toggle).not.toHaveBeenCalled(); + expect(setOpen).toHaveBeenCalledTimes(1); }); - it('calls toggle when shouldLaunchBrowser returns false', async () => { + it('calls setOpen when shouldLaunchBrowser returns false', async () => { const config = createMockConfig(); const toggle = vi.fn(); const setOpen = vi.fn(); @@ -488,15 +500,15 @@ describe('devtoolsService', () => { mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); mockDevToolsInstance.getPort.mockReturnValue(25417); - const promise = toggleDevToolsPanel(config, toggle, setOpen); + const promise = toggleDevToolsPanel(config, false, toggle, setOpen); await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); MockWebSocket.instances[0].simulateError(); await promise; - expect(toggle).toHaveBeenCalledTimes(1); - expect(setOpen).not.toHaveBeenCalled(); + expect(toggle).not.toHaveBeenCalled(); + expect(setOpen).toHaveBeenCalledTimes(1); }); it('calls setOpen when DevTools server fails to start', async () => { @@ -506,7 +518,7 @@ describe('devtoolsService', () => { mockDevToolsInstance.start.mockRejectedValue(new Error('fail')); - const promise = toggleDevToolsPanel(config, toggle, setOpen); + const promise = toggleDevToolsPanel(config, false, toggle, setOpen); await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); MockWebSocket.instances[0].simulateError(); diff --git a/packages/cli/src/utils/devtoolsService.ts b/packages/cli/src/utils/devtoolsService.ts index 35abf0ec964..5e4b7710c43 100644 --- a/packages/cli/src/utils/devtoolsService.ts +++ b/packages/cli/src/utils/devtoolsService.ts @@ -212,14 +212,24 @@ async function startDevToolsServerImpl(config: Config): Promise { /** * Handles the F12 key toggle for the DevTools panel. - * Starts the DevTools server, attempts to open the browser, - * and always calls the toggle callback regardless of the outcome. + * Starts the DevTools server, attempts to open the browser. + * If the panel is already open, it closes it. + * If the panel is closed: + * - Attempts to open the browser. + * - If browser opening is successful, the panel remains closed. + * - If browser opening fails or is not possible, the panel is opened. */ export async function toggleDevToolsPanel( config: Config, + isOpen: boolean, toggle: () => void, setOpen: () => void, ): Promise { + if (isOpen) { + toggle(); + return; + } + try { const { openBrowserSecurely, shouldLaunchBrowser } = await import( '@google/gemini-cli-core' @@ -228,11 +238,14 @@ export async function toggleDevToolsPanel( if (shouldLaunchBrowser()) { try { await openBrowserSecurely(url); + // Browser opened successfully, don't open drawer. + return; } catch (e) { debugLogger.warn('Failed to open browser securely:', e); } } - toggle(); + // If we can't launch browser or it failed, open drawer. + setOpen(); } catch (e) { setOpen(); debugLogger.error('Failed to start DevTools server:', e); diff --git a/packages/core/src/agents/agentLoader.test.ts b/packages/core/src/agents/agentLoader.test.ts index 3649558b642..a54626b637d 100644 --- a/packages/core/src/agents/agentLoader.test.ts +++ b/packages/core/src/agents/agentLoader.test.ts @@ -363,4 +363,171 @@ Hidden`, expect(result.errors).toHaveLength(1); }); }); + + describe('remote agent auth configuration', () => { + it('should parse remote agent with apiKey auth', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: api-key-agent +agent_card_url: https://example.com/card +auth: + type: apiKey + key: $MY_API_KEY + in: header + name: X-Custom-Key +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'remote', + name: 'api-key-agent', + auth: { + type: 'apiKey', + key: '$MY_API_KEY', + in: 'header', + name: 'X-Custom-Key', + }, + }); + }); + + it('should parse remote agent with http Bearer auth', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: bearer-agent +agent_card_url: https://example.com/card +auth: + type: http + scheme: Bearer + token: $BEARER_TOKEN +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'remote', + name: 'bearer-agent', + auth: { + type: 'http', + scheme: 'Bearer', + token: '$BEARER_TOKEN', + }, + }); + }); + + it('should parse remote agent with http Basic auth', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: basic-agent +agent_card_url: https://example.com/card +auth: + type: http + scheme: Basic + username: $AUTH_USER + password: $AUTH_PASS +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'remote', + name: 'basic-agent', + auth: { + type: 'http', + scheme: 'Basic', + username: '$AUTH_USER', + password: '$AUTH_PASS', + }, + }); + }); + + it('should throw error for Bearer auth without token', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: invalid-bearer +agent_card_url: https://example.com/card +auth: + type: http + scheme: Bearer +--- +`); + await expect(parseAgentMarkdown(filePath)).rejects.toThrow( + /Bearer scheme requires "token"/, + ); + }); + + it('should throw error for Basic auth without credentials', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: invalid-basic +agent_card_url: https://example.com/card +auth: + type: http + scheme: Basic + username: user +--- +`); + await expect(parseAgentMarkdown(filePath)).rejects.toThrow( + /Basic scheme requires "username" and "password"/, + ); + }); + + it('should throw error for apiKey auth without key', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: invalid-apikey +agent_card_url: https://example.com/card +auth: + type: apiKey +--- +`); + await expect(parseAgentMarkdown(filePath)).rejects.toThrow( + /auth\.key.*Required/, + ); + }); + + it('should convert auth config in markdownToAgentDefinition', () => { + const markdown = { + kind: 'remote' as const, + name: 'auth-agent', + agent_card_url: 'https://example.com/card', + auth: { + type: 'apiKey' as const, + key: '$API_KEY', + in: 'header' as const, + }, + }; + + const result = markdownToAgentDefinition(markdown); + expect(result).toMatchObject({ + kind: 'remote', + name: 'auth-agent', + auth: { + type: 'apiKey', + key: '$API_KEY', + location: 'header', + }, + }); + }); + + it('should parse auth with agent_card_requires_auth flag', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: protected-card-agent +agent_card_url: https://example.com/card +auth: + type: apiKey + key: $MY_API_KEY + agent_card_requires_auth: true +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result[0]).toMatchObject({ + auth: { + type: 'apiKey', + agent_card_requires_auth: true, + }, + }); + }); + }); }); diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts index 8d5e44b93c4..cb2a6057793 100644 --- a/packages/core/src/agents/agentLoader.ts +++ b/packages/core/src/agents/agentLoader.ts @@ -15,6 +15,7 @@ import { DEFAULT_MAX_TURNS, DEFAULT_MAX_TIME_MINUTES, } from './types.js'; +import type { A2AAuthConfig } from './auth-provider/types.js'; import { isValidToolName } from '../tools/tool-names.js'; import { FRONTMATTER_REGEX } from '../skills/skillLoader.js'; import { getErrorMessage } from '../utils/errors.js'; @@ -39,11 +40,29 @@ interface FrontmatterLocalAgentDefinition timeout_mins?: number; } +/** + * Authentication configuration for remote agents in frontmatter format. + */ +interface FrontmatterAuthConfig { + type: 'apiKey' | 'http'; + agent_card_requires_auth?: boolean; + // API Key + key?: string; + in?: 'header' | 'query' | 'cookie'; + name?: string; + // HTTP + scheme?: 'Bearer' | 'Basic'; + token?: string; + username?: string; + password?: string; +} + interface FrontmatterRemoteAgentDefinition extends FrontmatterBaseAgentDefinition { kind: 'remote'; description?: string; agent_card_url: string; + auth?: FrontmatterAuthConfig; } type FrontmatterAgentDefinition = @@ -95,6 +114,66 @@ const localAgentSchema = z }) .strict(); +/** + * Base fields shared by all auth configs. + */ +const baseAuthFields = { + agent_card_requires_auth: z.boolean().optional(), +}; + +/** + * API Key auth schema. + * Supports sending key in header, query parameter, or cookie. + */ +const apiKeyAuthSchema = z.object({ + ...baseAuthFields, + type: z.literal('apiKey'), + key: z.string().min(1, 'API key is required'), + in: z.enum(['header', 'query', 'cookie']).optional(), + name: z.string().optional(), +}); + +/** + * HTTP auth schema (Bearer or Basic). + * Note: Validation for scheme-specific fields is applied in authConfigSchema + * since discriminatedUnion doesn't support refined schemas directly. + */ +const httpAuthSchemaBase = z.object({ + ...baseAuthFields, + type: z.literal('http'), + scheme: z.enum(['Bearer', 'Basic']), + token: z.string().optional(), + username: z.string().optional(), + password: z.string().optional(), +}); + +/** + * Combined auth schema - discriminated union of all auth types. + * Note: We use the base schema for discriminatedUnion, then apply refinements + * via superRefine since discriminatedUnion doesn't support refined schemas directly. + */ +const authConfigSchema = z + .discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchemaBase]) + .superRefine((data, ctx) => { + // Apply HTTP auth validation after union parsing + if (data.type === 'http') { + if (data.scheme === 'Bearer' && !data.token) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Bearer scheme requires "token"', + path: ['token'], + }); + } + if (data.scheme === 'Basic' && (!data.username || !data.password)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Basic scheme requires "username" and "password"', + path: data.username ? ['password'] : ['username'], + }); + } + } + }); + const remoteAgentSchema = z .object({ kind: z.literal('remote').optional().default('remote'), @@ -102,6 +181,7 @@ const remoteAgentSchema = z description: z.string().optional(), display_name: z.string().optional(), agent_card_url: z.string().url(), + auth: authConfigSchema.optional(), }) .strict(); @@ -238,6 +318,76 @@ export async function parseAgentMarkdown( return [agentDef]; } +/** + * Converts frontmatter auth config to the internal A2AAuthConfig type. + * This handles the mapping from snake_case YAML to the internal type structure. + */ +function convertFrontmatterAuthToConfig( + frontmatter: FrontmatterAuthConfig, +): A2AAuthConfig { + const base = { + agent_card_requires_auth: frontmatter.agent_card_requires_auth, + }; + + switch (frontmatter.type) { + case 'apiKey': + if (!frontmatter.key) { + throw new Error('Internal error: API key missing after validation.'); + } + return { + ...base, + type: 'apiKey', + key: frontmatter.key, + location: frontmatter.in, + name: frontmatter.name, + }; + + case 'http': { + if (!frontmatter.scheme) { + throw new Error( + 'Internal error: HTTP scheme missing after validation.', + ); + } + switch (frontmatter.scheme) { + case 'Bearer': + if (!frontmatter.token) { + throw new Error( + 'Internal error: Bearer token missing after validation.', + ); + } + return { + ...base, + type: 'http', + scheme: 'Bearer', + token: frontmatter.token, + }; + case 'Basic': + if (!frontmatter.username || !frontmatter.password) { + throw new Error( + 'Internal error: Basic auth credentials missing after validation.', + ); + } + return { + ...base, + type: 'http', + scheme: 'Basic', + username: frontmatter.username, + password: frontmatter.password, + }; + default: { + const exhaustive: never = frontmatter.scheme; + throw new Error(`Unknown HTTP scheme: ${exhaustive}`); + } + } + } + + default: { + const exhaustive: never = frontmatter.type; + throw new Error(`Unknown auth type: ${exhaustive}`); + } + } +} + /** * Converts a FrontmatterAgentDefinition DTO to the internal AgentDefinition structure. * @@ -270,6 +420,9 @@ export function markdownToAgentDefinition( description: markdown.description || '(Loading description...)', displayName: markdown.display_name, agentCardUrl: markdown.agent_card_url, + auth: markdown.auth + ? convertFrontmatterAuthToConfig(markdown.auth) + : undefined, inputConfig, metadata, }; diff --git a/packages/core/src/agents/auth-provider/base-provider.ts b/packages/core/src/agents/auth-provider/base-provider.ts index 7b21853a096..7fb2e61acc8 100644 --- a/packages/core/src/agents/auth-provider/base-provider.ts +++ b/packages/core/src/agents/auth-provider/base-provider.ts @@ -9,17 +9,33 @@ import type { A2AAuthProvider, A2AAuthProviderType } from './types.js'; /** * Abstract base class for A2A authentication providers. + * Provides default implementations for optional methods. */ export abstract class BaseA2AAuthProvider implements A2AAuthProvider { + /** + * The type of authentication provider. + */ abstract readonly type: A2AAuthProviderType; + + /** + * Get the HTTP headers to include in requests. + * Subclasses must implement this method. + */ abstract headers(): Promise; private static readonly MAX_AUTH_RETRIES = 2; private authRetryCount = 0; /** - * Default: retry on 401/403 with fresh headers. - * Subclasses with cached tokens must override to force-refresh to avoid infinite retries. + * Check if a request should be retried with new headers. + * + * The default implementation checks for 401/403 status codes and + * returns fresh headers for retry. Subclasses can override for + * custom retry logic. + * + * @param _req The original request init + * @param res The response from the server + * @returns New headers for retry, or undefined if no retry should be made */ async shouldRetryWithHeaders( _req: RequestInit, @@ -32,10 +48,15 @@ export abstract class BaseA2AAuthProvider implements A2AAuthProvider { this.authRetryCount++; return this.headers(); } - // Reset on success + // Reset count if not an auth error this.authRetryCount = 0; return undefined; } - async initialize(): Promise {} + /** + * Initialize the provider. Override in subclasses that need async setup. + */ + async initialize(): Promise { + // Default: no-op + } } diff --git a/packages/core/src/agents/auth-provider/value-resolver.test.ts b/packages/core/src/agents/auth-provider/value-resolver.test.ts new file mode 100644 index 00000000000..58aa84c077b --- /dev/null +++ b/packages/core/src/agents/auth-provider/value-resolver.test.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + resolveAuthValue, + needsResolution, + maskSensitiveValue, +} from './value-resolver.js'; + +describe('value-resolver', () => { + describe('resolveAuthValue', () => { + describe('environment variables', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should resolve environment variable with $ prefix', async () => { + vi.stubEnv('TEST_API_KEY', 'secret-key-123'); + const result = await resolveAuthValue('$TEST_API_KEY'); + expect(result).toBe('secret-key-123'); + }); + + it('should throw error for unset environment variable', async () => { + await expect(resolveAuthValue('$UNSET_VAR_12345')).rejects.toThrow( + "Environment variable 'UNSET_VAR_12345' is not set or is empty", + ); + }); + + it('should throw error for empty environment variable', async () => { + vi.stubEnv('EMPTY_VAR', ''); + await expect(resolveAuthValue('$EMPTY_VAR')).rejects.toThrow( + "Environment variable 'EMPTY_VAR' is not set or is empty", + ); + }); + }); + + describe('shell commands', () => { + it('should execute shell command with ! prefix', async () => { + const result = await resolveAuthValue('!echo hello'); + expect(result).toBe('hello'); + }); + + it('should trim whitespace from command output', async () => { + const result = await resolveAuthValue('!echo " hello "'); + expect(result).toBe('hello'); + }); + + it('should throw error for empty command', async () => { + await expect(resolveAuthValue('!')).rejects.toThrow( + 'Empty command in auth value', + ); + }); + + it('should throw error for command that returns empty output', async () => { + await expect(resolveAuthValue('!echo -n ""')).rejects.toThrow( + 'returned empty output', + ); + }); + + it('should throw error for failed command', async () => { + await expect( + resolveAuthValue('!nonexistent-command-12345'), + ).rejects.toThrow(/Command.*failed/); + }); + }); + + describe('literal values', () => { + it('should return literal value as-is', async () => { + const result = await resolveAuthValue('literal-api-key'); + expect(result).toBe('literal-api-key'); + }); + + it('should return empty string as-is', async () => { + const result = await resolveAuthValue(''); + expect(result).toBe(''); + }); + + it('should not treat values starting with other characters as special', async () => { + const result = await resolveAuthValue('api-key-123'); + expect(result).toBe('api-key-123'); + }); + }); + + describe('escaped literals', () => { + it('should return $ literal when value starts with $$', async () => { + const result = await resolveAuthValue('$$LITERAL'); + expect(result).toBe('$LITERAL'); + }); + + it('should return ! literal when value starts with !!', async () => { + const result = await resolveAuthValue('!!not-a-command'); + expect(result).toBe('!not-a-command'); + }); + }); + }); + + describe('needsResolution', () => { + it('should return true for environment variable reference', () => { + expect(needsResolution('$ENV_VAR')).toBe(true); + }); + + it('should return true for command reference', () => { + expect(needsResolution('!command')).toBe(true); + }); + + it('should return false for literal value', () => { + expect(needsResolution('literal')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(needsResolution('')).toBe(false); + }); + }); + + describe('maskSensitiveValue', () => { + it('should mask value longer than 12 characters', () => { + expect(maskSensitiveValue('1234567890abcd')).toBe('12****cd'); + }); + + it('should return **** for short values', () => { + expect(maskSensitiveValue('short')).toBe('****'); + }); + + it('should return **** for exactly 12 characters', () => { + expect(maskSensitiveValue('123456789012')).toBe('****'); + }); + + it('should return **** for empty string', () => { + expect(maskSensitiveValue('')).toBe('****'); + }); + }); +}); diff --git a/packages/core/src/agents/auth-provider/value-resolver.ts b/packages/core/src/agents/auth-provider/value-resolver.ts new file mode 100644 index 00000000000..c349a574984 --- /dev/null +++ b/packages/core/src/agents/auth-provider/value-resolver.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { debugLogger } from '../../utils/debugLogger.js'; +import { getShellConfiguration, spawnAsync } from '../../utils/shell-utils.js'; + +const COMMAND_TIMEOUT_MS = 60_000; + +/** + * Resolves a value that may be an environment variable reference, + * a shell command, or a literal value. + * + * Supported formats: + * - `$ENV_VAR`: Read from environment variable + * - `!command`: Execute shell command and use output (trimmed) + * - `$$` or `!!`: Escape prefix, returns rest as literal + * - Any other string: Use as literal value + * + * @param value The value to resolve + * @returns The resolved value + * @throws Error if environment variable is not set or command fails + */ +export async function resolveAuthValue(value: string): Promise { + // Support escaping with double prefix (e.g. $$ or !!). + // Strips one prefix char: $$FOO → $FOO, !!cmd → !cmd (literal, not resolved). + if (value.startsWith('$$') || value.startsWith('!!')) { + return value.slice(1); + } + + // Environment variable: $MY_VAR + if (value.startsWith('$')) { + const envVar = value.slice(1); + const resolved = process.env[envVar]; + if (resolved === undefined || resolved === '') { + throw new Error( + `Environment variable '${envVar}' is not set or is empty. ` + + `Please set it before using this agent.`, + ); + } + debugLogger.debug(`[AuthValueResolver] Resolved env var: ${envVar}`); + return resolved; + } + + // Shell command: !command arg1 arg2 + if (value.startsWith('!')) { + const command = value.slice(1).trim(); + if (!command) { + throw new Error('Empty command in auth value. Expected format: !command'); + } + + debugLogger.debug(`[AuthValueResolver] Executing command for auth value`); + + const shellConfig = getShellConfiguration(); + try { + const { stdout } = await spawnAsync( + shellConfig.executable, + [...shellConfig.argsPrefix, command], + { + signal: AbortSignal.timeout(COMMAND_TIMEOUT_MS), + windowsHide: true, + }, + ); + + const trimmed = stdout.trim(); + if (!trimmed) { + throw new Error(`Command '${command}' returned empty output`); + } + return trimmed; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error( + `Command '${command}' timed out after ${COMMAND_TIMEOUT_MS / 1000} seconds`, + ); + } + throw error; + } + } + + // Literal value - return as-is + return value; +} + +/** + * Check if a value needs resolution (is an env var or command reference). + */ +export function needsResolution(value: string): boolean { + return value.startsWith('$') || value.startsWith('!'); +} + +/** + * Mask a sensitive value for logging purposes. + * Shows the first and last 2 characters with asterisks in between. + */ +export function maskSensitiveValue(value: string): string { + if (value.length <= 12) { + return '****'; + } + return `${value.slice(0, 2)}****${value.slice(-2)}`; +} diff --git a/packages/core/src/availability/fallbackIntegration.test.ts b/packages/core/src/availability/fallbackIntegration.test.ts index 55f9ac800f4..f9de1f3b2bf 100644 --- a/packages/core/src/availability/fallbackIntegration.test.ts +++ b/packages/core/src/availability/fallbackIntegration.test.ts @@ -58,7 +58,7 @@ describe('Fallback Integration', () => { ); }); - it('should NOT fallback if config is NOT in AUTO mode', () => { + it('should fallback for Gemini 3 models even if config is NOT in AUTO mode', () => { // 1. Config is explicitly set to Pro, not Auto vi.spyOn(config, 'getModel').mockReturnValue(PREVIEW_GEMINI_MODEL); @@ -71,7 +71,7 @@ describe('Fallback Integration', () => { // 4. Apply model selection const result = applyModelSelection(config, { model: requestedModel }); - // 5. Expect it to stay on Pro (because single model chain) - expect(result.model).toBe(PREVIEW_GEMINI_MODEL); + // 5. Expect it to fallback to Flash (because Gemini 3 uses PREVIEW_CHAIN) + expect(result.model).toBe(PREVIEW_GEMINI_FLASH_MODEL); }); }); diff --git a/packages/core/src/availability/policyHelpers.test.ts b/packages/core/src/availability/policyHelpers.test.ts index 4e923f638e2..298f17eb743 100644 --- a/packages/core/src/availability/policyHelpers.test.ts +++ b/packages/core/src/availability/policyHelpers.test.ts @@ -115,6 +115,19 @@ describe('policyHelpers', () => { expect(chain[0]?.model).toBe('gemini-2.5-flash'); expect(chain[1]?.model).toBe('gemini-2.5-pro'); }); + + it('proactively returns Gemini 2.5 chain if Gemini 3 requested but user lacks access', () => { + const config = createMockConfig({ + getModel: () => 'auto-gemini-3', + getHasAccessToPreviewModel: () => false, + }); + const chain = resolvePolicyChain(config); + + // Should downgrade to [Pro 2.5, Flash 2.5] + expect(chain).toHaveLength(2); + expect(chain[0]?.model).toBe('gemini-2.5-pro'); + expect(chain[1]?.model).toBe('gemini-2.5-flash'); + }); }); describe('buildFallbackPolicyContext', () => { diff --git a/packages/core/src/availability/policyHelpers.ts b/packages/core/src/availability/policyHelpers.ts index 4d65b84d77a..569157561f0 100644 --- a/packages/core/src/availability/policyHelpers.ts +++ b/packages/core/src/availability/policyHelpers.ts @@ -24,6 +24,7 @@ import { DEFAULT_GEMINI_MODEL, PREVIEW_GEMINI_MODEL_AUTO, isAutoModel, + isGemini3Model, resolveModel, } from '../config/models.js'; import type { ModelSelectionResult } from './modelAvailabilityService.js'; @@ -46,17 +47,32 @@ export function resolvePolicyChain( const resolvedModel = resolveModel(modelFromConfig); const isAutoPreferred = preferredModel ? isAutoModel(preferredModel) : false; const isAutoConfigured = isAutoModel(configuredModel); + const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? true; if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) { chain = getFlashLitePolicyChain(); - } else if (isAutoPreferred || isAutoConfigured) { - const previewEnabled = - preferredModel === PREVIEW_GEMINI_MODEL_AUTO || - configuredModel === PREVIEW_GEMINI_MODEL_AUTO; - chain = getModelPolicyChain({ - previewEnabled, - userTier: config.getUserTier(), - }); + } else if ( + isGemini3Model(resolvedModel) || + isAutoPreferred || + isAutoConfigured + ) { + if (hasAccessToPreview) { + const previewEnabled = + isGemini3Model(resolvedModel) || + preferredModel === PREVIEW_GEMINI_MODEL_AUTO || + configuredModel === PREVIEW_GEMINI_MODEL_AUTO; + chain = getModelPolicyChain({ + previewEnabled, + userTier: config.getUserTier(), + }); + } else { + // User requested Gemini 3 but has no access. Proactively downgrade + // to the stable Gemini 2.5 chain. + return getModelPolicyChain({ + previewEnabled: false, + userTier: config.getUserTier(), + }); + } } else { chain = createSingleModelChain(modelFromConfig); } diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 886e722ba07..4a732bbedb1 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -1036,6 +1036,44 @@ describe('Server Config (config.ts)', () => { expect(registeredWrappers).toHaveLength(1); }); + it('should register subagents as tools even when they are not in allowedTools', async () => { + const params: ConfigParameters = { + ...baseParams, + allowedTools: ['read_file'], // codebase-investigator is NOT here + agents: { + overrides: { + codebase_investigator: { enabled: true }, + }, + }, + }; + const config = new Config(params); + + const mockAgentDefinition = { + name: 'codebase-investigator', + description: 'Agent 1', + instructions: 'Inst 1', + }; + + const AgentRegistryMock = ( + (await vi.importMock('../agents/registry.js')) as { + AgentRegistry: Mock; + } + ).AgentRegistry; + AgentRegistryMock.prototype.getAllDefinitions.mockReturnValue([ + mockAgentDefinition, + ]); + + const SubAgentToolMock = ( + (await vi.importMock('../agents/subagent-tool.js')) as { + SubagentTool: Mock; + } + ).SubagentTool; + + await config.initialize(); + + expect(SubAgentToolMock).toHaveBeenCalled(); + }); + it('should not register subagents as tools when agents are disabled', async () => { const params: ConfigParameters = { ...baseParams, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 1570339010f..944d14fb39b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -383,7 +383,9 @@ export interface ConfigParameters { question?: string; coreTools?: string[]; + /** @deprecated Use Policy Engine instead */ allowedTools?: string[]; + /** @deprecated Use Policy Engine instead */ excludeTools?: string[]; toolDiscoveryCommand?: string; toolCallCommand?: string; @@ -516,7 +518,9 @@ export class Config { private readonly question: string | undefined; private readonly coreTools: string[] | undefined; + /** @deprecated Use Policy Engine instead */ private readonly allowedTools: string[] | undefined; + /** @deprecated Use Policy Engine instead */ private readonly excludeTools: string[] | undefined; private readonly toolDiscoveryCommand: string | undefined; private readonly toolCallCommand: string | undefined; @@ -905,6 +909,10 @@ export class Config { ); } + isInitialized(): boolean { + return this.initialized; + } + /** * Must only be called once, throws if called again. */ @@ -1483,11 +1491,12 @@ export class Config { /** * All the excluded tools from static configuration, loaded extensions, or - * other sources. + * other sources (like the Policy Engine). * * May change over time. */ getExcludeTools(): Set | undefined { + // Right now this is present for backward compatibility with settings.json exclude const excludeToolsSet = new Set([...(this.excludeTools ?? [])]); for (const extension of this.getExtensionLoader().getExtensions()) { if (!extension.isActive) { @@ -1497,6 +1506,12 @@ export class Config { excludeToolsSet.add(tool); } } + + const policyExclusions = this.policyEngine.getExcludedTools(); + for (const tool of policyExclusions) { + excludeToolsSet.add(tool); + } + return excludeToolsSet; } @@ -2454,26 +2469,16 @@ export class Config { agentsOverrides['codebase_investigator']?.enabled !== false || agentsOverrides['cli_help']?.enabled !== false ) { - const allowedTools = this.getAllowedTools(); const definitions = this.agentRegistry.getAllDefinitions(); for (const definition of definitions) { - const isAllowed = - !allowedTools || allowedTools.includes(definition.name); - - if (isAllowed) { - try { - const tool = new SubagentTool( - definition, - this, - this.getMessageBus(), - ); - registry.registerTool(tool); - } catch (e: unknown) { - debugLogger.warn( - `Failed to register tool for agent ${definition.name}: ${getErrorMessage(e)}`, - ); - } + try { + const tool = new SubagentTool(definition, this, this.getMessageBus()); + registry.registerTool(tool); + } catch (e: unknown) { + debugLogger.warn( + `Failed to register tool for agent ${definition.name}: ${getErrorMessage(e)}`, + ); } } } diff --git a/packages/core/src/config/defaultModelConfigs.ts b/packages/core/src/config/defaultModelConfigs.ts index 773223dc0c7..c0424de9e34 100644 --- a/packages/core/src/config/defaultModelConfigs.ts +++ b/packages/core/src/config/defaultModelConfigs.ts @@ -96,6 +96,12 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = { model: 'gemini-2.5-flash', }, }, + 'gemini-3-flash-base': { + extends: 'base', + modelConfig: { + model: 'gemini-3-flash-preview', + }, + }, classifier: { extends: 'base', modelConfig: { @@ -151,7 +157,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = { }, }, 'web-search': { - extends: 'gemini-2.5-flash-base', + extends: 'gemini-3-flash-base', modelConfig: { generateContentConfig: { tools: [{ googleSearch: {} }], @@ -159,7 +165,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = { }, }, 'web-fetch': { - extends: 'gemini-2.5-flash-base', + extends: 'gemini-3-flash-base', modelConfig: { generateContentConfig: { tools: [{ urlContext: {} }], @@ -168,25 +174,25 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = { }, // TODO(joshualitt): During cleanup, make modelConfig optional. 'web-fetch-fallback': { - extends: 'gemini-2.5-flash-base', + extends: 'gemini-3-flash-base', modelConfig: {}, }, 'loop-detection': { - extends: 'gemini-2.5-flash-base', + extends: 'gemini-3-flash-base', modelConfig: {}, }, 'loop-detection-double-check': { extends: 'base', modelConfig: { - model: 'gemini-2.5-pro', + model: 'gemini-3-pro-preview', }, }, 'llm-edit-fixer': { - extends: 'gemini-2.5-flash-base', + extends: 'gemini-3-flash-base', modelConfig: {}, }, 'next-speaker-checker': { - extends: 'gemini-2.5-flash-base', + extends: 'gemini-3-flash-base', modelConfig: {}, }, 'chat-compression-3-pro': { @@ -216,7 +222,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = { }, 'chat-compression-default': { modelConfig: { - model: 'gemini-2.5-pro', + model: 'gemini-3-pro-preview', }, }, }, diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index fe7fc85d037..e9445653663 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -519,6 +519,10 @@ exports[`Core System Prompt (prompts.ts) > should append userMemory with separat - **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders. - **Source Control:** Do not stage or commit changes unless specifically requested by the user. +## Context Efficiency: +- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase. +- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context. + ## Engineering Standards - **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt. - **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update. @@ -646,6 +650,10 @@ exports[`Core System Prompt (prompts.ts) > should handle CodebaseInvestigator wi - **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders. - **Source Control:** Do not stage or commit changes unless specifically requested by the user. +## Context Efficiency: +- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase. +- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context. + ## Engineering Standards - **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt. - **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update. @@ -738,6 +746,10 @@ exports[`Core System Prompt (prompts.ts) > should handle CodebaseInvestigator wi - **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders. - **Source Control:** Do not stage or commit changes unless specifically requested by the user. +## Context Efficiency: +- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase. +- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context. + ## Engineering Standards - **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt. - **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update. @@ -1299,6 +1311,10 @@ exports[`Core System Prompt (prompts.ts) > should include available_skills with - **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders. - **Source Control:** Do not stage or commit changes unless specifically requested by the user. +## Context Efficiency: +- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase. +- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context. + ## Engineering Standards - **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt. - **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update. @@ -1422,6 +1438,10 @@ exports[`Core System Prompt (prompts.ts) > should include correct sandbox instru - **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders. - **Source Control:** Do not stage or commit changes unless specifically requested by the user. +## Context Efficiency: +- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase. +- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context. + ## Engineering Standards - **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt. - **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update. @@ -1536,6 +1556,10 @@ exports[`Core System Prompt (prompts.ts) > should include correct sandbox instru - **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders. - **Source Control:** Do not stage or commit changes unless specifically requested by the user. +## Context Efficiency: +- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase. +- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context. + ## Engineering Standards - **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt. - **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update. @@ -1650,6 +1674,10 @@ exports[`Core System Prompt (prompts.ts) > should include correct sandbox instru - **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders. - **Source Control:** Do not stage or commit changes unless specifically requested by the user. +## Context Efficiency: +- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase. +- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context. + ## Engineering Standards - **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt. - **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update. @@ -1760,6 +1788,10 @@ exports[`Core System Prompt (prompts.ts) > should include planning phase suggest - **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders. - **Source Control:** Do not stage or commit changes unless specifically requested by the user. +## Context Efficiency: +- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase. +- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context. + ## Engineering Standards - **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt. - **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update. @@ -1870,6 +1902,10 @@ exports[`Core System Prompt (prompts.ts) > should include sub-agents in XML for - **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders. - **Source Control:** Do not stage or commit changes unless specifically requested by the user. +## Context Efficiency: +- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase. +- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context. + ## Engineering Standards - **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt. - **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update. @@ -2219,6 +2255,10 @@ exports[`Core System Prompt (prompts.ts) > should return the base prompt when us - **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders. - **Source Control:** Do not stage or commit changes unless specifically requested by the user. +## Context Efficiency: +- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase. +- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context. + ## Engineering Standards - **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt. - **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update. @@ -2329,6 +2369,10 @@ exports[`Core System Prompt (prompts.ts) > should return the base prompt when us - **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders. - **Source Control:** Do not stage or commit changes unless specifically requested by the user. +## Context Efficiency: +- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase. +- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context. + ## Engineering Standards - **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt. - **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update. @@ -2550,6 +2594,10 @@ exports[`Core System Prompt (prompts.ts) > should use chatty system prompt for p - **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders. - **Source Control:** Do not stage or commit changes unless specifically requested by the user. +## Context Efficiency: +- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase. +- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context. + ## Engineering Standards - **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt. - **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update. @@ -2660,6 +2708,10 @@ exports[`Core System Prompt (prompts.ts) > should use chatty system prompt for p - **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders. - **Source Control:** Do not stage or commit changes unless specifically requested by the user. +## Context Efficiency: +- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase. +- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context. + ## Engineering Standards - **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt. - **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update. diff --git a/packages/core/src/policy/config.test.ts b/packages/core/src/policy/config.test.ts index 25f7e4a1500..620cdd8500a 100644 --- a/packages/core/src/policy/config.test.ts +++ b/packages/core/src/policy/config.test.ts @@ -951,4 +951,107 @@ name = "invalid-name" vi.doUnmock('node:fs/promises'); }); + + it('should allow overriding Plan Mode deny with user policy', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + const normalizedPath = nodePath.normalize(path.toString()); + if (normalizedPath.includes(nodePath.normalize('.gemini/policies'))) { + return [ + { + name: 'user-plan.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + const normalizedPath = nodePath.normalize(path.toString()); + if (normalizedPath.includes('user-plan.toml')) { + return ` +[[rule]] +toolName = "run_shell_command" +commandPrefix = ["git status", "git diff"] +decision = "allow" +priority = 100 +modes = ["plan"] + +[[rule]] +toolName = "codebase_investigator" +decision = "allow" +priority = 100 +modes = ["plan"] +`; + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./config.js'); + + const settings: PolicySettings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.PLAN, + nodePath.join(__dirname, 'policies'), + ); + + const shellRules = config.rules?.filter( + (r) => + r.toolName === 'run_shell_command' && + r.decision === PolicyDecision.ALLOW && + r.modes?.includes(ApprovalMode.PLAN) && + r.argsPattern, + ); + expect(shellRules).toHaveLength(2); + expect( + shellRules?.some((r) => r.argsPattern?.test('{"command":"git status"}')), + ).toBe(true); + expect( + shellRules?.some((r) => r.argsPattern?.test('{"command":"git diff"}')), + ).toBe(true); + expect( + shellRules?.every( + (r) => !r.argsPattern?.test('{"command":"git commit"}'), + ), + ).toBe(true); + + const subagentRule = config.rules?.find( + (r) => + r.toolName === 'codebase_investigator' && + r.decision === PolicyDecision.ALLOW && + r.modes?.includes(ApprovalMode.PLAN), + ); + expect(subagentRule).toBeDefined(); + expect(subagentRule?.priority).toBeCloseTo(2.1, 5); + + vi.doUnmock('node:fs/promises'); + }); }); diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index 12aa94d8938..656c1008456 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -31,12 +31,12 @@ decision = "deny" priority = 60 modes = ["plan"] -deny_message = "You are in Plan Mode - adjust your prompt to only use read and search tools." +deny_message = "You are in Plan Mode with access to read-only tools. Execution of scripts (including those from skills) is blocked." # Explicitly Allow Read-Only Tools in Plan mode. [[rule]] -toolName = ["glob", "grep_search", "list_directory", "read_file", "google_web_search"] +toolName = ["glob", "grep_search", "list_directory", "read_file", "google_web_search", "activate_skill"] decision = "allow" priority = 70 modes = ["plan"] diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts index 6c59161af49..26aecaa1ebf 100644 --- a/packages/core/src/policy/policy-engine.test.ts +++ b/packages/core/src/policy/policy-engine.test.ts @@ -2031,6 +2031,156 @@ describe('PolicyEngine', () => { }); }); + describe('getExcludedTools', () => { + interface TestCase { + name: string; + rules: PolicyRule[]; + approvalMode?: ApprovalMode; + nonInteractive?: boolean; + expected: string[]; + } + + const testCases: TestCase[] = [ + { + name: 'should return empty set when no rules provided', + rules: [], + expected: [], + }, + { + name: 'should include tools with DENY decision', + rules: [ + { toolName: 'tool1', decision: PolicyDecision.DENY }, + { toolName: 'tool2', decision: PolicyDecision.ALLOW }, + ], + expected: ['tool1'], + }, + { + name: 'should respect priority and ignore lower priority rules (DENY wins)', + rules: [ + { toolName: 'tool1', decision: PolicyDecision.DENY, priority: 100 }, + { toolName: 'tool1', decision: PolicyDecision.ALLOW, priority: 10 }, + ], + expected: ['tool1'], + }, + { + name: 'should respect priority and ignore lower priority rules (ALLOW wins)', + rules: [ + { toolName: 'tool1', decision: PolicyDecision.ALLOW, priority: 100 }, + { toolName: 'tool1', decision: PolicyDecision.DENY, priority: 10 }, + ], + expected: [], + }, + { + name: 'should NOT include ASK_USER tools even in non-interactive mode', + rules: [{ toolName: 'tool1', decision: PolicyDecision.ASK_USER }], + nonInteractive: true, + expected: [], + }, + { + name: 'should ignore rules with argsPattern', + rules: [ + { + toolName: 'tool1', + decision: PolicyDecision.DENY, + argsPattern: /something/, + }, + ], + expected: [], + }, + { + name: 'should respect approval mode (PLAN mode)', + rules: [ + { + toolName: 'tool1', + decision: PolicyDecision.DENY, + modes: [ApprovalMode.PLAN], + }, + ], + approvalMode: ApprovalMode.PLAN, + expected: ['tool1'], + }, + { + name: 'should respect approval mode (DEFAULT mode)', + rules: [ + { + toolName: 'tool1', + decision: PolicyDecision.DENY, + modes: [ApprovalMode.PLAN], + }, + ], + approvalMode: ApprovalMode.DEFAULT, + expected: [], + }, + { + name: 'should respect wildcard ALLOW rules (e.g. YOLO mode)', + rules: [ + { + decision: PolicyDecision.ALLOW, + priority: 999, + modes: [ApprovalMode.YOLO], + }, + { + toolName: 'dangerous-tool', + decision: PolicyDecision.DENY, + priority: 10, + }, + ], + approvalMode: ApprovalMode.YOLO, + expected: [], + }, + { + name: 'should respect server wildcard DENY', + rules: [{ toolName: 'server__*', decision: PolicyDecision.DENY }], + expected: ['server__*'], + }, + { + name: 'should expand server wildcard for specific tools if already processed', + rules: [ + { + toolName: 'server__*', + decision: PolicyDecision.DENY, + priority: 100, + }, + { + toolName: 'server__tool1', + decision: PolicyDecision.DENY, + priority: 10, + }, + ], + expected: ['server__*', 'server__tool1'], + }, + { + name: 'should NOT exclude tool if covered by a higher priority wildcard ALLOW', + rules: [ + { + toolName: 'server__*', + decision: PolicyDecision.ALLOW, + priority: 100, + }, + { + toolName: 'server__tool1', + decision: PolicyDecision.DENY, + priority: 10, + }, + ], + expected: [], + }, + ]; + + it.each(testCases)( + '$name', + ({ rules, approvalMode, nonInteractive, expected }) => { + engine = new PolicyEngine({ + rules, + approvalMode: approvalMode ?? ApprovalMode.DEFAULT, + nonInteractive: nonInteractive ?? false, + }); + const excluded = engine.getExcludedTools(); + expect(Array.from(excluded).sort()).toEqual(expected.sort()); + }, + ); + }); + describe('YOLO mode with ask_user tool', () => { it('should return ASK_USER for ask_user tool even in YOLO mode', async () => { const rules: PolicyRule[] = [ @@ -2086,4 +2236,44 @@ describe('PolicyEngine', () => { expect(result.decision).toBe(PolicyDecision.ALLOW); }); }); + + describe('Plan Mode', () => { + it('should allow activate_skill but deny shell commands in Plan Mode', async () => { + const rules: PolicyRule[] = [ + { + decision: PolicyDecision.DENY, + priority: 60, + modes: [ApprovalMode.PLAN], + denyMessage: + 'You are in Plan Mode with access to read-only tools. Execution of scripts (including those from skills) is blocked.', + }, + { + toolName: 'activate_skill', + decision: PolicyDecision.ALLOW, + priority: 70, + modes: [ApprovalMode.PLAN], + }, + ]; + + engine = new PolicyEngine({ + rules, + approvalMode: ApprovalMode.PLAN, + }); + + const skillResult = await engine.check( + { name: 'activate_skill', args: { name: 'test' } }, + undefined, + ); + expect(skillResult.decision).toBe(PolicyDecision.ALLOW); + + const shellResult = await engine.check( + { name: 'run_shell_command', args: { command: 'ls' } }, + undefined, + ); + expect(shellResult.decision).toBe(PolicyDecision.DENY); + expect(shellResult.rule?.denyMessage).toContain( + 'Execution of scripts (including those from skills) is blocked', + ); + }); + }); }); diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts index 8a643c89304..1fc5e7cde52 100644 --- a/packages/core/src/policy/policy-engine.ts +++ b/packages/core/src/policy/policy-engine.ts @@ -26,6 +26,22 @@ import { } from '../utils/shell-utils.js'; import { getToolAliases } from '../tools/tool-names.js'; +function isWildcardPattern(name: string): boolean { + return name.endsWith('__*'); +} + +function getWildcardPrefix(pattern: string): string { + return pattern.slice(0, -3); +} + +function matchesWildcard(pattern: string, toolName: string): boolean { + if (!isWildcardPattern(pattern)) { + return false; + } + const prefix = getWildcardPrefix(pattern); + return toolName.startsWith(prefix + '__'); +} + function ruleMatches( rule: PolicyRule | SafetyCheckerRule, toolCall: FunctionCall, @@ -43,8 +59,8 @@ function ruleMatches( // Check tool name if specified if (rule.toolName) { // Support wildcard patterns: "serverName__*" matches "serverName__anyTool" - if (rule.toolName.endsWith('__*')) { - const prefix = rule.toolName.slice(0, -3); // Remove "__*" + if (isWildcardPattern(rule.toolName)) { + const prefix = getWildcardPrefix(rule.toolName); if (serverName !== undefined) { // Robust check: if serverName is provided, it MUST match the prefix exactly. // This prevents "malicious-server" from spoofing "trusted-server" by naming itself "trusted-server__malicious". @@ -53,7 +69,7 @@ function ruleMatches( } } // Always verify the prefix, even if serverName matched - if (!toolCall.name || !toolCall.name.startsWith(prefix + '__')) { + if (!toolCall.name || !matchesWildcard(rule.toolName, toolCall.name)) { return false; } } else if (toolCall.name !== rule.toolName) { @@ -509,6 +525,90 @@ export class PolicyEngine { return this.hookCheckers; } + /** + * Get tools that are effectively denied by the current rules. + * This takes into account: + * 1. Global rules (no argsPattern) + * 2. Priority order (higher priority wins) + * 3. Non-interactive mode (ASK_USER becomes DENY) + */ + getExcludedTools(): Set { + const excludedTools = new Set(); + const processedTools = new Set(); + let globalVerdict: PolicyDecision | undefined; + + for (const rule of this.rules) { + // We only care about rules without args pattern for exclusion from the model + if (rule.argsPattern) { + continue; + } + + // Check if rule applies to current approval mode + if (rule.modes && rule.modes.length > 0) { + if (!rule.modes.includes(this.approvalMode)) { + continue; + } + } + + // Handle Global Rules + if (!rule.toolName) { + if (globalVerdict === undefined) { + globalVerdict = rule.decision; + if (globalVerdict !== PolicyDecision.DENY) { + // Global ALLOW/ASK found. + // Since rules are sorted by priority, this overrides any lower-priority rules. + // We can stop processing because nothing else will be excluded. + break; + } + // If Global DENY, we continue to find specific tools to add to excluded set + } + continue; + } + + const toolName = rule.toolName; + + // Check if already processed (exact match) + if (processedTools.has(toolName)) { + continue; + } + + // Check if covered by a processed wildcard + let coveredByWildcard = false; + for (const processed of processedTools) { + if ( + isWildcardPattern(processed) && + matchesWildcard(processed, toolName) + ) { + // It's covered by a higher-priority wildcard rule. + // If that wildcard rule resulted in exclusion, this tool should also be excluded. + if (excludedTools.has(processed)) { + excludedTools.add(toolName); + } + coveredByWildcard = true; + break; + } + } + if (coveredByWildcard) { + continue; + } + + processedTools.add(toolName); + + // Determine decision + let decision: PolicyDecision; + if (globalVerdict !== undefined) { + decision = globalVerdict; + } else { + decision = rule.decision; + } + + if (decision === PolicyDecision.DENY) { + excludedTools.add(toolName); + } + } + return excludedTools; + } + private applyNonInteractiveMode(decision: PolicyDecision): PolicyDecision { // In non-interactive mode, ASK_USER becomes DENY if (this.nonInteractive && decision === PolicyDecision.ASK_USER) { diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index 16b3feaa439..3dcf346de6e 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -164,6 +164,10 @@ export function renderCoreMandates(options?: CoreMandatesOptions): string { - **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders. - **Source Control:** Do not stage or commit changes unless specifically requested by the user. +## Context Efficiency: +- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase. +- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context. + ## Engineering Standards - **Contextual Precedence:** Instructions found in ${formattedFilenames} files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt. - **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update. diff --git a/packages/core/src/services/environmentSanitization.test.ts b/packages/core/src/services/environmentSanitization.test.ts index 97f7e575ca5..cc26d7547d8 100644 --- a/packages/core/src/services/environmentSanitization.test.ts +++ b/packages/core/src/services/environmentSanitization.test.ts @@ -46,9 +46,6 @@ describe('sanitizeEnvironment', () => { CLIENT_ID: 'sensitive-id', DB_URI: 'sensitive-uri', DATABASE_URL: 'sensitive-url', - GEMINI_API_KEY: 'sensitive-gemini-key', - GOOGLE_API_KEY: 'sensitive-google-key', - GOOGLE_APPLICATION_CREDENTIALS: '/path/to/creds.json', SAFE_VAR: 'is-safe', }; const sanitized = sanitizeEnvironment(env, EMPTY_OPTIONS); diff --git a/packages/core/src/services/environmentSanitization.ts b/packages/core/src/services/environmentSanitization.ts index b30b229079c..dc9c92484de 100644 --- a/packages/core/src/services/environmentSanitization.ts +++ b/packages/core/src/services/environmentSanitization.ts @@ -103,9 +103,6 @@ export const NEVER_ALLOWED_ENVIRONMENT_VARIABLES: ReadonlySet = new Set( 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_ACCOUNT', 'FIREBASE_PROJECT_ID', - 'GEMINI_API_KEY', - 'GOOGLE_API_KEY', - 'GOOGLE_APPLICATION_CREDENTIALS', ], ); diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 23ac63f7721..96cae8c2692 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -32,6 +32,18 @@ const { Terminal } = pkg; const MAX_CHILD_PROCESS_BUFFER_SIZE = 16 * 1024 * 1024; // 16MB +/** + * An environment variable that is set for shell executions. This can be used + * by downstream executables and scripts to identify that they were executed + * from within Gemini CLI. + */ +export const GEMINI_CLI_IDENTIFICATION_ENV_VAR = 'GEMINI_CLI'; + +/** + * The value of {@link GEMINI_CLI_IDENTIFICATION_ENV_VAR} + */ +export const GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE = '1'; + // We want to allow shell outputs that are close to the context window in size. // 300,000 lines is roughly equivalent to a large context window, ensuring // we capture significant output from long-running commands. @@ -302,7 +314,8 @@ export class ShellExecutionService { detached: !isWindows, env: { ...sanitizeEnvironment(process.env, sanitizationConfig), - GEMINI_CLI: '1', + [GEMINI_CLI_IDENTIFICATION_ENV_VAR]: + GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE, TERM: 'xterm-256color', PAGER: 'cat', GIT_PAGER: 'cat', diff --git a/packages/core/src/services/test-data/resolved-aliases-retry.golden.json b/packages/core/src/services/test-data/resolved-aliases-retry.golden.json index 3b38b909d85..9bfd252b88d 100644 --- a/packages/core/src/services/test-data/resolved-aliases-retry.golden.json +++ b/packages/core/src/services/test-data/resolved-aliases-retry.golden.json @@ -104,6 +104,13 @@ "topP": 1 } }, + "gemini-3-flash-base": { + "model": "gemini-3-flash-preview", + "generateContentConfig": { + "temperature": 0, + "topP": 1 + } + }, "classifier": { "model": "gemini-2.5-flash-lite", "generateContentConfig": { @@ -153,7 +160,7 @@ } }, "web-search": { - "model": "gemini-2.5-flash", + "model": "gemini-3-flash-preview", "generateContentConfig": { "temperature": 0, "topP": 1, @@ -165,7 +172,7 @@ } }, "web-fetch": { - "model": "gemini-2.5-flash", + "model": "gemini-3-flash-preview", "generateContentConfig": { "temperature": 0, "topP": 1, @@ -177,35 +184,35 @@ } }, "web-fetch-fallback": { - "model": "gemini-2.5-flash", + "model": "gemini-3-flash-preview", "generateContentConfig": { "temperature": 0, "topP": 1 } }, "loop-detection": { - "model": "gemini-2.5-flash", + "model": "gemini-3-flash-preview", "generateContentConfig": { "temperature": 0, "topP": 1 } }, "loop-detection-double-check": { - "model": "gemini-2.5-pro", + "model": "gemini-3-pro-preview", "generateContentConfig": { "temperature": 0, "topP": 1 } }, "llm-edit-fixer": { - "model": "gemini-2.5-flash", + "model": "gemini-3-flash-preview", "generateContentConfig": { "temperature": 0, "topP": 1 } }, "next-speaker-checker": { - "model": "gemini-2.5-flash", + "model": "gemini-3-flash-preview", "generateContentConfig": { "temperature": 0, "topP": 1 @@ -232,7 +239,7 @@ "generateContentConfig": {} }, "chat-compression-default": { - "model": "gemini-2.5-pro", + "model": "gemini-3-pro-preview", "generateContentConfig": {} } } diff --git a/packages/core/src/services/test-data/resolved-aliases.golden.json b/packages/core/src/services/test-data/resolved-aliases.golden.json index 3b38b909d85..9bfd252b88d 100644 --- a/packages/core/src/services/test-data/resolved-aliases.golden.json +++ b/packages/core/src/services/test-data/resolved-aliases.golden.json @@ -104,6 +104,13 @@ "topP": 1 } }, + "gemini-3-flash-base": { + "model": "gemini-3-flash-preview", + "generateContentConfig": { + "temperature": 0, + "topP": 1 + } + }, "classifier": { "model": "gemini-2.5-flash-lite", "generateContentConfig": { @@ -153,7 +160,7 @@ } }, "web-search": { - "model": "gemini-2.5-flash", + "model": "gemini-3-flash-preview", "generateContentConfig": { "temperature": 0, "topP": 1, @@ -165,7 +172,7 @@ } }, "web-fetch": { - "model": "gemini-2.5-flash", + "model": "gemini-3-flash-preview", "generateContentConfig": { "temperature": 0, "topP": 1, @@ -177,35 +184,35 @@ } }, "web-fetch-fallback": { - "model": "gemini-2.5-flash", + "model": "gemini-3-flash-preview", "generateContentConfig": { "temperature": 0, "topP": 1 } }, "loop-detection": { - "model": "gemini-2.5-flash", + "model": "gemini-3-flash-preview", "generateContentConfig": { "temperature": 0, "topP": 1 } }, "loop-detection-double-check": { - "model": "gemini-2.5-pro", + "model": "gemini-3-pro-preview", "generateContentConfig": { "temperature": 0, "topP": 1 } }, "llm-edit-fixer": { - "model": "gemini-2.5-flash", + "model": "gemini-3-flash-preview", "generateContentConfig": { "temperature": 0, "topP": 1 } }, "next-speaker-checker": { - "model": "gemini-2.5-flash", + "model": "gemini-3-flash-preview", "generateContentConfig": { "temperature": 0, "topP": 1 @@ -232,7 +239,7 @@ "generateContentConfig": {} }, "chat-compression-default": { - "model": "gemini-2.5-pro", + "model": "gemini-3-pro-preview", "generateContentConfig": {} } } diff --git a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap index a61afbd2b94..038e2008353 100644 --- a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap +++ b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap @@ -45,6 +45,10 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps "description": "Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.", "type": "string", }, + "exclude_pattern": { + "description": "Optional: A regular expression pattern to exclude from the search results. If a line matches both the pattern and the exclude_pattern, it will be omitted.", + "type": "string", + }, "include": { "description": "Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).", "type": "string", @@ -54,6 +58,10 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps "minimum": 1, "type": "integer", }, + "names_only": { + "description": "Optional: If true, only the file paths of the matches will be returned, without the line content or line numbers. This is useful for gathering a list of files.", + "type": "boolean", + }, "pattern": { "description": "The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').", "type": "string", @@ -254,6 +262,10 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > "description": "Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.", "type": "string", }, + "exclude_pattern": { + "description": "Optional: A regular expression pattern to exclude from the search results. If a line matches both the pattern and the exclude_pattern, it will be omitted.", + "type": "string", + }, "include": { "description": "Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).", "type": "string", @@ -263,6 +275,10 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > "minimum": 1, "type": "integer", }, + "names_only": { + "description": "Optional: If true, only the file paths of the matches will be returned, without the line content or line numbers. This is useful for gathering a list of files.", + "type": "boolean", + }, "pattern": { "description": "The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').", "type": "string", diff --git a/packages/core/src/tools/definitions/coreTools.ts b/packages/core/src/tools/definitions/coreTools.ts index b87a6a10e46..9cab2019b1c 100644 --- a/packages/core/src/tools/definitions/coreTools.ts +++ b/packages/core/src/tools/definitions/coreTools.ts @@ -98,6 +98,16 @@ export const GREP_DEFINITION: ToolDefinition = { description: `Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).`, type: 'string', }, + exclude_pattern: { + description: + 'Optional: A regular expression pattern to exclude from the search results. If a line matches both the pattern and the exclude_pattern, it will be omitted.', + type: 'string', + }, + names_only: { + description: + 'Optional: If true, only the file paths of the matches will be returned, without the line content or line numbers. This is useful for gathering a list of files.', + type: 'boolean', + }, max_matches_per_file: { description: 'Optional: Maximum number of matches to return per file. Use this to prevent being overwhelmed by repetitive matches in large files.', diff --git a/packages/core/src/tools/grep.test.ts b/packages/core/src/tools/grep.test.ts index 743116f0f14..cecc32d5f1c 100644 --- a/packages/core/src/tools/grep.test.ts +++ b/packages/core/src/tools/grep.test.ts @@ -498,6 +498,41 @@ describe('GrepTool', () => { expect(result.llmContent).toContain('File: sub/fileC.txt'); expect(result.llmContent).toContain('L1: another world in sub dir'); }); + + it('should return only file paths when names_only is true', async () => { + const params: GrepToolParams = { + pattern: 'world', + names_only: true, + }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('Found 2 files with matches'); + expect(result.llmContent).toContain('fileA.txt'); + expect(result.llmContent).toContain('sub/fileC.txt'); + expect(result.llmContent).not.toContain('L1:'); + expect(result.llmContent).not.toContain('hello world'); + }); + + it('should filter out matches based on exclude_pattern', async () => { + await fs.writeFile( + path.join(tempRootDir, 'copyright.txt'), + 'Copyright 2025 Google LLC\nCopyright 2026 Google LLC', + ); + + const params: GrepToolParams = { + pattern: 'Copyright .* Google LLC', + exclude_pattern: '2026', + dir_path: '.', + }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('Found 1 match'); + expect(result.llmContent).toContain('copyright.txt'); + expect(result.llmContent).toContain('Copyright 2025 Google LLC'); + expect(result.llmContent).not.toContain('Copyright 2026 Google LLC'); + }); }); describe('getDescription', () => { diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index fb8fde2bd80..b1fdb9474ce 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -49,6 +49,16 @@ export interface GrepToolParams { */ include?: string; + /** + * Optional: A regular expression pattern to exclude from the search results. + */ + exclude_pattern?: string; + + /** + * Optional: If true, only the file paths of the matches will be returned. + */ + names_only?: boolean; + /** * Optional: Maximum number of matches to return per file. Use this to prevent being overwhelmed by repetitive matches in large files. */ @@ -225,6 +235,7 @@ class GrepToolInvocation extends BaseToolInvocation< pattern: this.params.pattern, path: searchDir, include: this.params.include, + exclude_pattern: this.params.exclude_pattern, maxMatches: remainingLimit, max_matches_per_file: this.params.max_matches_per_file, signal: timeoutController.signal, @@ -280,6 +291,16 @@ class GrepToolInvocation extends BaseToolInvocation< const matchCount = allMatches.length; const matchTerm = matchCount === 1 ? 'match' : 'matches'; + if (this.params.names_only) { + const filePaths = Object.keys(matchesByFile).sort(); + let llmContent = `Found ${filePaths.length} files with matches for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}${wasTruncated ? ` (results limited to ${totalMaxMatches} matches for performance)` : ''}:\n`; + llmContent += filePaths.join('\n'); + return { + llmContent: llmContent.trim(), + returnDisplay: `Found ${filePaths.length} files${wasTruncated ? ' (limited)' : ''}`, + }; + } + let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}`; if (wasTruncated) { @@ -354,6 +375,7 @@ class GrepToolInvocation extends BaseToolInvocation< pattern: string; path: string; // Expects absolute path include?: string; + exclude_pattern?: string; maxMatches: number; max_matches_per_file?: number; signal: AbortSignal; @@ -362,12 +384,18 @@ class GrepToolInvocation extends BaseToolInvocation< pattern, path: absolutePath, include, + exclude_pattern, maxMatches, max_matches_per_file, } = options; let strategyUsed = 'none'; try { + let excludeRegex: RegExp | null = null; + if (exclude_pattern) { + excludeRegex = new RegExp(exclude_pattern, 'i'); + } + // --- Strategy 1: git grep --- const isGit = isGitRepository(absolutePath); const gitAvailable = isGit && (await this.isCommandAvailable('git')); @@ -400,6 +428,9 @@ class GrepToolInvocation extends BaseToolInvocation< for await (const line of generator) { const match = this.parseGrepLine(line, absolutePath); if (match) { + if (excludeRegex && excludeRegex.test(match.line)) { + continue; + } results.push(match); if (results.length >= maxMatches) { break; @@ -467,6 +498,9 @@ class GrepToolInvocation extends BaseToolInvocation< for await (const line of generator) { const match = this.parseGrepLine(line, absolutePath); if (match) { + if (excludeRegex && excludeRegex.test(match.line)) { + continue; + } results.push(match); if (results.length >= maxMatches) { break; @@ -528,6 +562,9 @@ class GrepToolInvocation extends BaseToolInvocation< for (let index = 0; index < lines.length; index++) { const line = lines[index]; if (regex.test(line)) { + if (excludeRegex && excludeRegex.test(line)) { + continue; + } allMatches.push({ filePath: path.relative(absolutePath, fileAbsolutePath) || @@ -637,6 +674,14 @@ export class GrepTool extends BaseDeclarativeTool { return `Invalid regular expression pattern provided: ${params.pattern}. Error: ${getErrorMessage(error)}`; } + if (params.exclude_pattern) { + try { + new RegExp(params.exclude_pattern); + } catch (error) { + return `Invalid exclude regular expression pattern provided: ${params.exclude_pattern}. Error: ${getErrorMessage(error)}`; + } + } + if ( params.max_matches_per_file !== undefined && params.max_matches_per_file < 1 diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index 77dec9d6573..3f289f17322 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -1623,7 +1623,7 @@ describe('mcp-client', () => { { command: 'test-command', args: ['--foo', 'bar'], - env: { GEMINI_CLI_FOO: 'bar' }, + env: { FOO: 'bar' }, cwd: 'test/cwd', }, false, @@ -1634,46 +1634,12 @@ describe('mcp-client', () => { command: 'test-command', args: ['--foo', 'bar'], cwd: 'test/cwd', - env: expect.objectContaining({ GEMINI_CLI_FOO: 'bar' }), + env: expect.objectContaining({ FOO: 'bar' }), stderr: 'pipe', }); }); - it('should redact sensitive environment variables for command transport', async () => { - const mockedTransport = vi - .spyOn(SdkClientStdioLib, 'StdioClientTransport') - .mockReturnValue({} as SdkClientStdioLib.StdioClientTransport); - - const originalEnv = process.env; - process.env = { - ...originalEnv, - GEMINI_API_KEY: 'sensitive-key', - GEMINI_CLI_SAFE_VAR: 'safe-value', - }; - // Ensure strict sanitization is not triggered for this test - delete process.env['GITHUB_SHA']; - delete process.env['SURFACE']; - - try { - await createTransport( - 'test-server', - { - command: 'test-command', - }, - false, - EMPTY_CONFIG, - ); - - const callArgs = mockedTransport.mock.calls[0][0]; - expect(callArgs.env).toBeDefined(); - expect(callArgs.env!['GEMINI_CLI_SAFE_VAR']).toBe('safe-value'); - expect(callArgs.env!['GEMINI_API_KEY']).toBeUndefined(); - } finally { - process.env = originalEnv; - } - }); - - it('should include extension settings in environment', async () => { + it('sets an env variable GEMINI_CLI=1 for stdio MCP servers', async () => { const mockedTransport = vi .spyOn(SdkClientStdioLib, 'StdioClientTransport') .mockReturnValue({} as SdkClientStdioLib.StdioClientTransport); @@ -1682,22 +1648,9 @@ describe('mcp-client', () => { 'test-server', { command: 'test-command', - extension: { - name: 'test-ext', - resolvedSettings: [ - { - envVar: 'GEMINI_CLI_EXT_VAR', - value: 'ext-value', - sensitive: false, - name: 'ext-setting', - }, - ], - version: '', - isActive: false, - path: '', - contextFiles: [], - id: '', - }, + args: ['--foo', 'bar'], + env: {}, + cwd: 'test/cwd', }, false, EMPTY_CONFIG, @@ -1705,7 +1658,7 @@ describe('mcp-client', () => { const callArgs = mockedTransport.mock.calls[0][0]; expect(callArgs.env).toBeDefined(); - expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBe('ext-value'); + expect(callArgs.env!['GEMINI_CLI']).toBe('1'); }); it('should exclude extension settings with undefined values from environment', async () => { diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index c069f7a2113..7902d8953a4 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -34,11 +34,7 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { ApprovalMode, PolicyDecision } from '../policy/types.js'; import { parse } from 'shell-quote'; -import type { - Config, - GeminiCLIExtension, - MCPServerConfig, -} from '../config/config.js'; +import type { Config, MCPServerConfig } from '../config/config.js'; import { AuthProviderType } from '../config/config.js'; import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js'; import { ServiceAccountImpersonationProvider } from '../mcp/sa-impersonation-provider.js'; @@ -71,6 +67,10 @@ import { sanitizeEnvironment, type EnvironmentSanitizationConfig, } from '../services/environmentSanitization.js'; +import { + GEMINI_CLI_IDENTIFICATION_ENV_VAR, + GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE, +} from '../services/shellExecutionService.js'; export const MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000; // default to 10 minutes @@ -1901,24 +1901,12 @@ export async function createTransport( let transport: Transport = new StdioClientTransport({ command: mcpServerConfig.command, args: mcpServerConfig.args || [], - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - env: sanitizeEnvironment( - { - ...process.env, - ...getExtensionEnvironment(mcpServerConfig.extension), - ...(mcpServerConfig.env || {}), - }, - { - ...sanitizationConfig, - allowedEnvironmentVariables: [ - ...(sanitizationConfig.allowedEnvironmentVariables ?? []), - ...(mcpServerConfig.extension?.resolvedSettings?.map( - (s) => s.envVar, - ) ?? []), - ], - enableEnvironmentVariableRedaction: true, - }, - ) as Record, + env: { + ...sanitizeEnvironment(process.env, sanitizationConfig), + ...(mcpServerConfig.env || {}), + [GEMINI_CLI_IDENTIFICATION_ENV_VAR]: + GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE, + } as Record, cwd: mcpServerConfig.cwd, stderr: 'pipe', }); @@ -1993,17 +1981,3 @@ export function isEnabled( ) ); } - -function getExtensionEnvironment( - extension?: GeminiCLIExtension, -): Record { - const env: Record = {}; - if (extension?.resolvedSettings) { - for (const setting of extension.resolvedSettings) { - if (setting.value) { - env[setting.envVar] = setting.value; - } - } - } - return env; -} diff --git a/packages/core/src/tools/ripGrep.test.ts b/packages/core/src/tools/ripGrep.test.ts index be3c298d762..114b6b8e118 100644 --- a/packages/core/src/tools/ripGrep.test.ts +++ b/packages/core/src/tools/ripGrep.test.ts @@ -1930,6 +1930,85 @@ describe('RipGrepTool', () => { expect(result.llmContent).not.toContain('L3: match 3'); expect(result.returnDisplay).toBe('Found 2 matches (limited)'); }); + + it('should return only file paths when names_only is true', async () => { + mockSpawn.mockImplementationOnce( + createMockSpawn({ + outputData: + JSON.stringify({ + type: 'match', + data: { + path: { text: 'fileA.txt' }, + line_number: 1, + lines: { text: 'hello world\n' }, + }, + }) + + '\n' + + JSON.stringify({ + type: 'match', + data: { + path: { text: 'fileB.txt' }, + line_number: 5, + lines: { text: 'hello again\n' }, + }, + }) + + '\n', + exitCode: 0, + }), + ); + + const params: RipGrepToolParams = { + pattern: 'hello', + names_only: true, + }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('Found 2 files with matches'); + expect(result.llmContent).toContain('fileA.txt'); + expect(result.llmContent).toContain('fileB.txt'); + expect(result.llmContent).not.toContain('L1:'); + expect(result.llmContent).not.toContain('hello world'); + }); + + it('should filter out matches based on exclude_pattern', async () => { + mockSpawn.mockImplementationOnce( + createMockSpawn({ + outputData: + JSON.stringify({ + type: 'match', + data: { + path: { text: 'fileA.txt' }, + line_number: 1, + lines: { text: 'Copyright 2025 Google LLC\n' }, + }, + }) + + '\n' + + JSON.stringify({ + type: 'match', + data: { + path: { text: 'fileB.txt' }, + line_number: 1, + lines: { text: 'Copyright 2026 Google LLC\n' }, + }, + }) + + '\n', + exitCode: 0, + }), + ); + + const params: RipGrepToolParams = { + pattern: 'Copyright .* Google LLC', + exclude_pattern: '2026', + }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('Found 1 match'); + expect(result.llmContent).toContain('fileA.txt'); + expect(result.llmContent).not.toContain('fileB.txt'); + expect(result.llmContent).toContain('Copyright 2025 Google LLC'); + }); }); }); diff --git a/packages/core/src/tools/ripGrep.ts b/packages/core/src/tools/ripGrep.ts index c2e27bcdbe1..c7855c28086 100644 --- a/packages/core/src/tools/ripGrep.ts +++ b/packages/core/src/tools/ripGrep.ts @@ -102,6 +102,16 @@ export interface RipGrepToolParams { */ include?: string; + /** + * Optional: A regular expression pattern to exclude from the search results. + */ + exclude_pattern?: string; + + /** + * Optional: If true, only the file paths of the matches will be returned. + */ + names_only?: boolean; + /** * If true, searches case-sensitively. Defaults to false. */ @@ -244,6 +254,7 @@ class GrepToolInvocation extends BaseToolInvocation< pattern: this.params.pattern, path: searchDirAbs, include: this.params.include, + exclude_pattern: this.params.exclude_pattern, case_sensitive: this.params.case_sensitive, fixed_strings: this.params.fixed_strings, context: this.params.context, @@ -299,6 +310,16 @@ class GrepToolInvocation extends BaseToolInvocation< const wasTruncated = matchCount >= totalMaxMatches; + if (this.params.names_only) { + const filePaths = Object.keys(matchesByFile).sort(); + let llmContent = `Found ${filePaths.length} files with matches for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}${wasTruncated ? ` (results limited to ${totalMaxMatches} matches for performance)` : ''}:\n`; + llmContent += filePaths.join('\n'); + return { + llmContent: llmContent.trim(), + returnDisplay: `Found ${filePaths.length} files${wasTruncated ? ' (limited)' : ''}`, + }; + } + let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}${wasTruncated ? ` (results limited to ${totalMaxMatches} matches for performance)` : ''}:\n---\n`; for (const filePath in matchesByFile) { @@ -330,6 +351,7 @@ class GrepToolInvocation extends BaseToolInvocation< pattern: string; path: string; include?: string; + exclude_pattern?: string; case_sensitive?: boolean; fixed_strings?: boolean; context?: number; @@ -344,6 +366,7 @@ class GrepToolInvocation extends BaseToolInvocation< pattern, path: absolutePath, include, + exclude_pattern, case_sensitive, fixed_strings, context, @@ -423,9 +446,18 @@ class GrepToolInvocation extends BaseToolInvocation< }); let matchesFound = 0; + let excludeRegex: RegExp | null = null; + if (exclude_pattern) { + excludeRegex = new RegExp(exclude_pattern, case_sensitive ? '' : 'i'); + } + for await (const line of generator) { const match = this.parseRipgrepJsonLine(line, absolutePath); if (match) { + if (excludeRegex && excludeRegex.test(match.line)) { + continue; + } + results.push(match); if (!match.isContext) { matchesFound++; @@ -527,7 +559,7 @@ export class RipGrepTool extends BaseDeclarativeTool< super( RipGrepTool.Name, 'SearchText', - 'Searches for a regular expression pattern within file contents. Max 100 matches.', + 'Searches for a regular expression pattern within file contents.', Kind.Search, { properties: { @@ -546,6 +578,16 @@ export class RipGrepTool extends BaseDeclarativeTool< "Glob pattern to filter files (e.g., '*.ts', 'src/**'). Recommended for large repositories to reduce noise. Defaults to all files if omitted.", type: 'string', }, + exclude_pattern: { + description: + 'Optional: A regular expression pattern to exclude from the search results. If a line matches both the pattern and the exclude_pattern, it will be omitted.', + type: 'string', + }, + names_only: { + description: + 'Optional: If true, only the file paths of the matches will be returned, without the line content or line numbers. This is useful for gathering a list of files.', + type: 'boolean', + }, case_sensitive: { description: 'If true, search is case-sensitive. Defaults to false (ignore case) if omitted.', @@ -565,11 +607,13 @@ export class RipGrepTool extends BaseDeclarativeTool< description: 'Show this many lines after each match (equivalent to grep -A). Defaults to 0 if omitted.', type: 'integer', + minimum: 0, }, before: { description: 'Show this many lines before each match (equivalent to grep -B). Defaults to 0 if omitted.', type: 'integer', + minimum: 0, }, no_ignore: { description: @@ -618,6 +662,14 @@ export class RipGrepTool extends BaseDeclarativeTool< } } + if (params.exclude_pattern) { + try { + new RegExp(params.exclude_pattern); + } catch (error) { + return `Invalid exclude regular expression pattern provided: ${params.exclude_pattern}. Error: ${getErrorMessage(error)}`; + } + } + if ( params.max_matches_per_file !== undefined && params.max_matches_per_file < 1 diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 70e882ebe19..f837edbe290 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -108,6 +108,7 @@ export const PLAN_MODE_TOOLS = [ LS_TOOL_NAME, WEB_SEARCH_TOOL_NAME, ASK_USER_TOOL_NAME, + ACTIVATE_SKILL_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME, ] as const; diff --git a/packages/core/src/utils/headless.test.ts b/packages/core/src/utils/headless.test.ts index 89f42ffcd60..4708c79969d 100644 --- a/packages/core/src/utils/headless.test.ts +++ b/packages/core/src/utils/headless.test.ts @@ -99,16 +99,50 @@ describe('isHeadlessMode', () => { expect(isHeadlessMode({ prompt: true })).toBe(true); }); - it('should return false if query is provided but it is still a TTY', () => { - // Note: per current logic, query alone doesn't force headless if TTY - // This matches the existing behavior in packages/cli/src/config/config.ts - expect(isHeadlessMode({ query: 'test query' })).toBe(false); + it('should return true if query is provided', () => { + expect(isHeadlessMode({ query: 'test query' })).toBe(true); + }); + + it('should return true if -p or --prompt is in process.argv as a fallback', () => { + const originalArgv = process.argv; + process.argv = ['node', 'index.js', '-p', 'hello']; + try { + expect(isHeadlessMode()).toBe(true); + } finally { + process.argv = originalArgv; + } + + process.argv = ['node', 'index.js', '--prompt', 'hello']; + try { + expect(isHeadlessMode()).toBe(true); + } finally { + process.argv = originalArgv; + } + }); + + it('should return true if -y or --yolo is in process.argv as a fallback', () => { + const originalArgv = process.argv; + process.argv = ['node', 'index.js', '-y']; + try { + expect(isHeadlessMode()).toBe(true); + } finally { + process.argv = originalArgv; + } + + process.argv = ['node', 'index.js', '--yolo']; + try { + expect(isHeadlessMode()).toBe(true); + } finally { + process.argv = originalArgv; + } }); it('should handle undefined process.stdout gracefully', () => { const originalStdout = process.stdout; - // @ts-expect-error - testing edge case - delete process.stdout; + Object.defineProperty(process, 'stdout', { + value: undefined, + configurable: true, + }); try { expect(isHeadlessMode()).toBe(false); @@ -122,8 +156,10 @@ describe('isHeadlessMode', () => { it('should handle undefined process.stdin gracefully', () => { const originalStdin = process.stdin; - // @ts-expect-error - testing edge case - delete process.stdin; + Object.defineProperty(process, 'stdin', { + value: undefined, + configurable: true, + }); try { expect(isHeadlessMode()).toBe(false); diff --git a/packages/core/src/utils/headless.ts b/packages/core/src/utils/headless.ts index 27ea5f9cbfa..5a46b90d6d4 100644 --- a/packages/core/src/utils/headless.ts +++ b/packages/core/src/utils/headless.ts @@ -28,18 +28,25 @@ export interface HeadlessModeOptions { * @returns true if the environment is considered headless. */ export function isHeadlessMode(options?: HeadlessModeOptions): boolean { - if (process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true') { - return ( - !!options?.prompt || - (!!process.stdin && !process.stdin.isTTY) || - (!!process.stdout && !process.stdout.isTTY) - ); + if (process.env['GEMINI_CLI_INTEGRATION_TEST'] !== 'true') { + const isCI = + process.env['CI'] === 'true' || process.env['GITHUB_ACTIONS'] === 'true'; + if (isCI) { + return true; + } } - return ( - process.env['CI'] === 'true' || - process.env['GITHUB_ACTIONS'] === 'true' || - !!options?.prompt || + + const isNotTTY = (!!process.stdin && !process.stdin.isTTY) || - (!!process.stdout && !process.stdout.isTTY) + (!!process.stdout && !process.stdout.isTTY); + + if (isNotTTY || !!options?.prompt || !!options?.query) { + return true; + } + + // Fallback: check process.argv for flags that imply headless or auto-approve mode. + return process.argv.some( + (arg) => + arg === '-p' || arg === '--prompt' || arg === '-y' || arg === '--yolo', ); } diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index dd2702e7123..06ad0379290 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -514,7 +514,7 @@ "modelConfigs": { "title": "Model Configs", "description": "Model configurations.", - "markdownDescription": "Model configurations.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{\n \"aliases\": {\n \"base\": {\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 0,\n \"topP\": 1\n }\n }\n },\n \"chat-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"includeThoughts\": true\n },\n \"temperature\": 1,\n \"topP\": 0.95,\n \"topK\": 64\n }\n }\n },\n \"chat-base-2.5\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 8192\n }\n }\n }\n },\n \"chat-base-3\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingLevel\": \"HIGH\"\n }\n }\n }\n },\n \"gemini-3-pro-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"gemini-3-flash-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"gemini-2.5-pro\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"gemini-2.5-flash\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"gemini-2.5-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"classifier\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 1024,\n \"thinkingConfig\": {\n \"thinkingBudget\": 512\n }\n }\n }\n },\n \"prompt-completion\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.3,\n \"maxOutputTokens\": 16000,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"edit-corrector\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"summarizer-default\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"summarizer-shell\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"web-search\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"googleSearch\": {}\n }\n ]\n }\n }\n },\n \"web-fetch\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"urlContext\": {}\n }\n ]\n }\n }\n },\n \"web-fetch-fallback\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection-double-check\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"llm-edit-fixer\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"next-speaker-checker\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"chat-compression-3-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"chat-compression-3-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"chat-compression-2.5-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"chat-compression-2.5-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"chat-compression-2.5-flash-lite\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"chat-compression-default\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n }\n },\n \"overrides\": [\n {\n \"match\": {\n \"model\": \"chat-base\",\n \"isRetry\": true\n },\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 1\n }\n }\n }\n ]\n}`", + "markdownDescription": "Model configurations.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{\n \"aliases\": {\n \"base\": {\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 0,\n \"topP\": 1\n }\n }\n },\n \"chat-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"includeThoughts\": true\n },\n \"temperature\": 1,\n \"topP\": 0.95,\n \"topK\": 64\n }\n }\n },\n \"chat-base-2.5\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 8192\n }\n }\n }\n },\n \"chat-base-3\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingLevel\": \"HIGH\"\n }\n }\n }\n },\n \"gemini-3-pro-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"gemini-3-flash-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"gemini-2.5-pro\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"gemini-2.5-flash\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"gemini-2.5-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-3-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"classifier\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 1024,\n \"thinkingConfig\": {\n \"thinkingBudget\": 512\n }\n }\n }\n },\n \"prompt-completion\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.3,\n \"maxOutputTokens\": 16000,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"edit-corrector\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"summarizer-default\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"summarizer-shell\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"web-search\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"googleSearch\": {}\n }\n ]\n }\n }\n },\n \"web-fetch\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"urlContext\": {}\n }\n ]\n }\n }\n },\n \"web-fetch-fallback\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection-double-check\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"llm-edit-fixer\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"next-speaker-checker\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"chat-compression-3-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"chat-compression-3-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"chat-compression-2.5-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"chat-compression-2.5-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"chat-compression-2.5-flash-lite\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"chat-compression-default\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n }\n },\n \"overrides\": [\n {\n \"match\": {\n \"model\": \"chat-base\",\n \"isRetry\": true\n },\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 1\n }\n }\n }\n ]\n}`", "default": { "aliases": { "base": { @@ -594,6 +594,12 @@ "model": "gemini-2.5-flash" } }, + "gemini-3-flash-base": { + "extends": "base", + "modelConfig": { + "model": "gemini-3-flash-preview" + } + }, "classifier": { "extends": "base", "modelConfig": { @@ -649,7 +655,7 @@ } }, "web-search": { - "extends": "gemini-2.5-flash-base", + "extends": "gemini-3-flash-base", "modelConfig": { "generateContentConfig": { "tools": [ @@ -661,7 +667,7 @@ } }, "web-fetch": { - "extends": "gemini-2.5-flash-base", + "extends": "gemini-3-flash-base", "modelConfig": { "generateContentConfig": { "tools": [ @@ -673,25 +679,25 @@ } }, "web-fetch-fallback": { - "extends": "gemini-2.5-flash-base", + "extends": "gemini-3-flash-base", "modelConfig": {} }, "loop-detection": { - "extends": "gemini-2.5-flash-base", + "extends": "gemini-3-flash-base", "modelConfig": {} }, "loop-detection-double-check": { "extends": "base", "modelConfig": { - "model": "gemini-2.5-pro" + "model": "gemini-3-pro-preview" } }, "llm-edit-fixer": { - "extends": "gemini-2.5-flash-base", + "extends": "gemini-3-flash-base", "modelConfig": {} }, "next-speaker-checker": { - "extends": "gemini-2.5-flash-base", + "extends": "gemini-3-flash-base", "modelConfig": {} }, "chat-compression-3-pro": { @@ -721,7 +727,7 @@ }, "chat-compression-default": { "modelConfig": { - "model": "gemini-2.5-pro" + "model": "gemini-3-pro-preview" } } }, @@ -744,7 +750,7 @@ "aliases": { "title": "Model Config Aliases", "description": "Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.", - "markdownDescription": "Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{\n \"base\": {\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 0,\n \"topP\": 1\n }\n }\n },\n \"chat-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"includeThoughts\": true\n },\n \"temperature\": 1,\n \"topP\": 0.95,\n \"topK\": 64\n }\n }\n },\n \"chat-base-2.5\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 8192\n }\n }\n }\n },\n \"chat-base-3\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingLevel\": \"HIGH\"\n }\n }\n }\n },\n \"gemini-3-pro-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"gemini-3-flash-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"gemini-2.5-pro\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"gemini-2.5-flash\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"gemini-2.5-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"classifier\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 1024,\n \"thinkingConfig\": {\n \"thinkingBudget\": 512\n }\n }\n }\n },\n \"prompt-completion\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.3,\n \"maxOutputTokens\": 16000,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"edit-corrector\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"summarizer-default\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"summarizer-shell\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"web-search\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"googleSearch\": {}\n }\n ]\n }\n }\n },\n \"web-fetch\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"urlContext\": {}\n }\n ]\n }\n }\n },\n \"web-fetch-fallback\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection-double-check\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"llm-edit-fixer\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"next-speaker-checker\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"chat-compression-3-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"chat-compression-3-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"chat-compression-2.5-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"chat-compression-2.5-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"chat-compression-2.5-flash-lite\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"chat-compression-default\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n }\n}`", + "markdownDescription": "Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{\n \"base\": {\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 0,\n \"topP\": 1\n }\n }\n },\n \"chat-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"includeThoughts\": true\n },\n \"temperature\": 1,\n \"topP\": 0.95,\n \"topK\": 64\n }\n }\n },\n \"chat-base-2.5\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 8192\n }\n }\n }\n },\n \"chat-base-3\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingLevel\": \"HIGH\"\n }\n }\n }\n },\n \"gemini-3-pro-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"gemini-3-flash-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"gemini-2.5-pro\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"gemini-2.5-flash\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"gemini-2.5-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-3-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"classifier\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 1024,\n \"thinkingConfig\": {\n \"thinkingBudget\": 512\n }\n }\n }\n },\n \"prompt-completion\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.3,\n \"maxOutputTokens\": 16000,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"edit-corrector\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"summarizer-default\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"summarizer-shell\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"web-search\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"googleSearch\": {}\n }\n ]\n }\n }\n },\n \"web-fetch\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"urlContext\": {}\n }\n ]\n }\n }\n },\n \"web-fetch-fallback\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection-double-check\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"llm-edit-fixer\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"next-speaker-checker\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"chat-compression-3-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"chat-compression-3-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"chat-compression-2.5-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"chat-compression-2.5-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"chat-compression-2.5-flash-lite\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"chat-compression-default\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n }\n}`", "default": { "base": { "modelConfig": { @@ -823,6 +829,12 @@ "model": "gemini-2.5-flash" } }, + "gemini-3-flash-base": { + "extends": "base", + "modelConfig": { + "model": "gemini-3-flash-preview" + } + }, "classifier": { "extends": "base", "modelConfig": { @@ -878,7 +890,7 @@ } }, "web-search": { - "extends": "gemini-2.5-flash-base", + "extends": "gemini-3-flash-base", "modelConfig": { "generateContentConfig": { "tools": [ @@ -890,7 +902,7 @@ } }, "web-fetch": { - "extends": "gemini-2.5-flash-base", + "extends": "gemini-3-flash-base", "modelConfig": { "generateContentConfig": { "tools": [ @@ -902,25 +914,25 @@ } }, "web-fetch-fallback": { - "extends": "gemini-2.5-flash-base", + "extends": "gemini-3-flash-base", "modelConfig": {} }, "loop-detection": { - "extends": "gemini-2.5-flash-base", + "extends": "gemini-3-flash-base", "modelConfig": {} }, "loop-detection-double-check": { "extends": "base", "modelConfig": { - "model": "gemini-2.5-pro" + "model": "gemini-3-pro-preview" } }, "llm-edit-fixer": { - "extends": "gemini-2.5-flash-base", + "extends": "gemini-3-flash-base", "modelConfig": {} }, "next-speaker-checker": { - "extends": "gemini-2.5-flash-base", + "extends": "gemini-3-flash-base", "modelConfig": {} }, "chat-compression-3-pro": { @@ -950,7 +962,7 @@ }, "chat-compression-default": { "modelConfig": { - "model": "gemini-2.5-pro" + "model": "gemini-3-pro-preview" } } },