diff --git a/.github/scripts/gemini-lifecycle-manager.cjs b/.github/scripts/gemini-lifecycle-manager.cjs index 6a32beeb53..a5d5b13e0e 100644 --- a/.github/scripts/gemini-lifecycle-manager.cjs +++ b/.github/scripts/gemini-lifecycle-manager.cjs @@ -41,6 +41,41 @@ module.exports = async ({ github, context, core }) => { now.getTime() - NO_RESPONSE_DAYS * 24 * 60 * 60 * 1000, ); + const maintainerCache = new Map(); + async function isMaintainer(user, association) { + if (user?.type === 'Bot') return true; + if (['OWNER', 'MEMBER', 'COLLABORATOR'].includes(association)) return true; + + const username = user?.login; + if (!username) return false; + + if (maintainerCache.has(username)) { + return maintainerCache.get(username); + } + + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username, + }); + // Permission can be admin, write, read, none. + // Roles like 'maintain' or 'triage' often map to 'write' or 'read' in the top-level field. + const isM = + ['admin', 'write'].includes(data.permission) || + ['admin', 'maintain', 'write'].includes(data.role_name); + + maintainerCache.set(username, isM); + return isM; + } catch (err) { + core.warning( + `Could not check permissions for ${username}: ${err.message}`, + ); + maintainerCache.set(username, false); + return false; + } + } + async function processItems(query, callback) { core.info(`Searching: ${query}`); try { @@ -83,10 +118,7 @@ module.exports = async ({ github, context, core }) => { const lastComment = comments[0]; if ( lastComment && - !['OWNER', 'MEMBER', 'COLLABORATOR'].includes( - lastComment.author_association, - ) && - lastComment.user?.type !== 'Bot' + !(await isMaintainer(lastComment.user, lastComment.author_association)) ) { core.info( `Removing ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`, @@ -188,11 +220,7 @@ module.exports = async ({ github, context, core }) => { await processItems( `repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"🔒 maintainer only" -label:"status/pr-nudge-sent" created:${prCloseThreshold.toISOString()}..${nudgeThreshold.toISOString()}`, async (pr) => { - if ( - ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) || - pr.user?.type === 'Bot' - ) - return; + if (await isMaintainer(pr.user, pr.author_association)) return; core.info(`Nudging PR #${pr.number} for contribution policy.`); if (!dryRun) { @@ -216,11 +244,7 @@ module.exports = async ({ github, context, core }) => { await processItems( `repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"🔒 maintainer only" created:<${prCloseThreshold.toISOString()}`, async (pr) => { - if ( - ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) || - pr.user?.type === 'Bot' - ) - return; + if (await isMaintainer(pr.user, pr.author_association)) return; core.info( `Closing PR #${pr.number} per contribution policy (no 'help wanted').`, diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md index 16411f3c98..48c6d1c154 100644 --- a/docs/changelogs/index.md +++ b/docs/changelogs/index.md @@ -18,6 +18,20 @@ on GitHub. | [Preview](preview.md) | Experimental features ready for early feedback. | | [Stable](latest.md) | Stable, recommended for general use. | +## Announcements: v0.41.0 - 2026-05-05 + +- **Real-time Voice Mode:** Implemented real-time voice mode with cloud and + local backends + ([#24174](https://github.com/google-gemini/gemini-cli/pull/24174) by + @Abhijit-2592). +- **Secure Environment Loading:** Enforced workspace trust and secured .env + loading in headless mode + ([#25814](https://github.com/google-gemini/gemini-cli/pull/25814) by + @ehedlund). +- **Advanced Shell Validation:** Enhanced shell command validation and added + core tools allowlist for improved security + ([#25720](https://github.com/google-gemini/gemini-cli/pull/25720) by @galz10). + ## Announcements: v0.40.0 - 2026-04-28 - **Offline Search and Themes:** Bundled ripgrep for offline search support and diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md index 6de16b2e9a..7429300dab 100644 --- a/docs/changelogs/latest.md +++ b/docs/changelogs/latest.md @@ -1,6 +1,6 @@ -# Latest stable release: v0.40.0 +# Latest stable release: v0.41.0 -Released: April 28, 2026 +Released: May 05, 2026 For most users, our latest stable release is the recommended release. Install the latest stable version with: @@ -11,177 +11,119 @@ npm install -g @google/gemini-cli ## Highlights -- **Offline Search Support:** Bundled ripgrep binaries into the Single - Executable Application (SEA) to enable powerful codebase searching even in - environments without internet access. -- **Enhanced Theme Customization:** Introduced GitHub-style colorblind-friendly - themes to improve accessibility and provide more personalized visual options. -- **MCP Resource Management:** Added new tools for listing and reading Model - Context Protocol (MCP) resources, enhancing the agent's ability to discover - and utilize external data. -- **Improved Narrative Flow:** Enabled topic update narrations by default to - provide better session structure and a clearer understanding of the agent's - current focus. -- **Streamlined Local Model Setup:** Introduced a simplified `gemini gemma` - command for quickly setting up and running Gemma models locally. -- **Prompt-Driven Memory Management:** Replaced the legacy `MemoryManagerAgent` - with a more efficient prompt-driven memory editing system across four tiers of - context. +- **Real-time Voice Mode:** Introduced support for real-time voice interaction + with both cloud-based and local processing backends. +- **Enhanced Security:** Implemented mandatory workspace trust for headless + environments and secured the loading of `.env` configuration files. +- **Advanced Shell Validation:** Added a robust shell command validation layer + and a core tools allowlist to prevent unauthorized execution. +- **Improved Context Management:** Integrated a new `ContextManager` and + `AgentChatHistory` to provide more reliable and efficient session handling. +- **Auto-Memory Persistence:** Enabled the persistence of the auto-memory + scratchpad, allowing for seamless skill extraction across turns. ## What's Changed -- chore(release): bump version to 0.40.0-nightly.20260414.g5b1f7375a by +- chore(release): bump version to 0.41.0-nightly.20260423.gaa05b4583 by @gemini-cli-robot in - [#25420](https://github.com/google-gemini/gemini-cli/pull/25420) -- Fix(core): retry additional OpenSSL 3.x SSL errors during streaming (#16075) - by @rcleveng in - [#25187](https://github.com/google-gemini/gemini-cli/pull/25187) -- fix(core): prevent YOLO mode from being downgraded by @galz10 in - [#25341](https://github.com/google-gemini/gemini-cli/pull/25341) -- feat: bundle ripgrep binaries into SEA for offline support by @scidomino in - [#25342](https://github.com/google-gemini/gemini-cli/pull/25342) -- Changelog for v0.39.0-preview.0 by @gemini-cli-robot in - [#25417](https://github.com/google-gemini/gemini-cli/pull/25417) -- feat(test): add large conversation scenario for performance test by + [#25847](https://github.com/google-gemini/gemini-cli/pull/25847) +- fix(core): only show `list` suggestion if the partial input is empty by @cynthialong0-0 in - [#25331](https://github.com/google-gemini/gemini-cli/pull/25331) -- improve(core): require recurrence evidence before extracting skills by + [#25821](https://github.com/google-gemini/gemini-cli/pull/25821) +- feat(cli): secure .env loading and enforce workspace trust in headless mode by + @ehedlund in [#25814](https://github.com/google-gemini/gemini-cli/pull/25814) +- fix: fatal hard-crash on loop detection via unhandled AbortError by @hsm207 in + [#20108](https://github.com/google-gemini/gemini-cli/pull/20108) +- update package-lock.json by @ehedlund in + [#25876](https://github.com/google-gemini/gemini-cli/pull/25876) +- feat(core): enhance shell command validation and add core tools allowlist by + @galz10 in [#25720](https://github.com/google-gemini/gemini-cli/pull/25720) +- fix(ui): corrected background color check in user message components by + @devr0306 in [#25880](https://github.com/google-gemini/gemini-cli/pull/25880) +- perf(core): fix slow boot by fetching experiments and quota asynchronously by + @spencer426 in + [#25758](https://github.com/google-gemini/gemini-cli/pull/25758) +- feat(core,cli): add support for Gemma 4 models (experimental) by @Abhijit-2592 + in [#25604](https://github.com/google-gemini/gemini-cli/pull/25604) +- update FatalUntrustedWorkspaceError message to include doc link by @ehedlund + in [#25874](https://github.com/google-gemini/gemini-cli/pull/25874) +- docs: add Gemini CLI course link to README by @JayadityaGit in + [#25925](https://github.com/google-gemini/gemini-cli/pull/25925) +- feat(repo): add gemini-cli-bot metrics and workflows by @gundermanc in + [#25888](https://github.com/google-gemini/gemini-cli/pull/25888) +- fix(cli): allow output redirection for cli commands by @spencer426 in + [#25894](https://github.com/google-gemini/gemini-cli/pull/25894) +- fix(core): fail closed in YOLO mode when shell parsing fails for restricted + rules by @ehedlund in + [#25935](https://github.com/google-gemini/gemini-cli/pull/25935) +- fix(cli-ui): revert backspace handling to fix Windows regression by @scidomino + in [#25941](https://github.com/google-gemini/gemini-cli/pull/25941) +- feat(voice): implement real-time voice mode with cloud and local backends by + @Abhijit-2592 in + [#24174](https://github.com/google-gemini/gemini-cli/pull/24174) +- Changelog for v0.39.0 by @gemini-cli-robot in + [#25848](https://github.com/google-gemini/gemini-cli/pull/25848) +- feat(memory): persist auto-memory scratchpad for skill extraction by @SandyTao520 in - [#25147](https://github.com/google-gemini/gemini-cli/pull/25147) -- test(evals): add subagent delegation evaluation tests by @anj-s in - [#24619](https://github.com/google-gemini/gemini-cli/pull/24619) -- feat: add github colorblind themes by @Z1xus in - [#15504](https://github.com/google-gemini/gemini-cli/pull/15504) -- fix(core): honor GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL by - @chrisjcthomas in - [#25357](https://github.com/google-gemini/gemini-cli/pull/25357) -- fix(cli): clean up slash command IDE listeners by @jasonmatthewsuhari in - [#24397](https://github.com/google-gemini/gemini-cli/pull/24397) -- Changelog for v0.38.0 by @gemini-cli-robot in - [#25470](https://github.com/google-gemini/gemini-cli/pull/25470) -- fix(evals): update eval tests for invoke_agent telemetry and project-scoped - memory by @SandyTao520 in - [#25502](https://github.com/google-gemini/gemini-cli/pull/25502) -- Changelog for v0.38.1 by @gemini-cli-robot in - [#25476](https://github.com/google-gemini/gemini-cli/pull/25476) -- feat(core): integrate skill-creator into skill extraction agent by - @SandyTao520 in - [#25421](https://github.com/google-gemini/gemini-cli/pull/25421) -- feat(cli): provide default post-submit prompt for skill command by @ruomengz - in [#25327](https://github.com/google-gemini/gemini-cli/pull/25327) -- feat(core): add tools to list and read MCP resources by @ruomengz in - [#25395](https://github.com/google-gemini/gemini-cli/pull/25395) -- fix(evals): add typecheck coverage for evals, integration-tests, and - memory-tests by @SandyTao520 in - [#25480](https://github.com/google-gemini/gemini-cli/pull/25480) -- Use OSC 777 for terminal notifications by @jackyliuxx in - [#25300](https://github.com/google-gemini/gemini-cli/pull/25300) -- fix(extensions): fix bundling for examples by @abhipatel12 in - [#25542](https://github.com/google-gemini/gemini-cli/pull/25542) -- fix(cli): reset plan session state on /clear by @jasonmatthewsuhari in - [#25515](https://github.com/google-gemini/gemini-cli/pull/25515) -- feat(core): add .mdx support to get-internal-docs tool by @g-samroberts in - [#25090](https://github.com/google-gemini/gemini-cli/pull/25090) -- docs(policy): mention that workspace policies are broken by @6112 in - [#24367](https://github.com/google-gemini/gemini-cli/pull/24367) -- fix(core): allow explicit write permissions to override governance file - protections in sandboxes by @galz10 in - [#25338](https://github.com/google-gemini/gemini-cli/pull/25338) -- feat(sandbox): resolve custom seatbelt profiles from $HOME/.gemini first by - @mvanhorn in [#25427](https://github.com/google-gemini/gemini-cli/pull/25427) -- Reduce blank lines. by @gundermanc in - [#25563](https://github.com/google-gemini/gemini-cli/pull/25563) -- fix(ui): revert preview theme on dialog unmount by @JayadityaGit in - [#22542](https://github.com/google-gemini/gemini-cli/pull/22542) -- fix(core): fix ShellExecutionConfig spread and add ProjectRegistry save - backoff by @mahimashanware in - [#25382](https://github.com/google-gemini/gemini-cli/pull/25382) -- feat(core): Disable topic updates for subagents by @gundermanc in - [#25567](https://github.com/google-gemini/gemini-cli/pull/25567) -- feat(core): enable topic update narration by default and promote to general by - @gundermanc in - [#25586](https://github.com/google-gemini/gemini-cli/pull/25586) -- docs: migrate installation and authentication to mdx with tabbed layouts by - @g-samroberts in - [#25155](https://github.com/google-gemini/gemini-cli/pull/25155) -- feat(config): split memoryManager flag into autoMemory by @SandyTao520 in - [#25601](https://github.com/google-gemini/gemini-cli/pull/25601) -- fix(core): allow Cloud Shell users to use PRO_MODEL_NO_ACCESS experiment by - @sehoon38 in [#25702](https://github.com/google-gemini/gemini-cli/pull/25702) -- fix(cli): round slow render latency to avoid opentelemetry float warning by - @scidomino in [#25709](https://github.com/google-gemini/gemini-cli/pull/25709) -- docs(tracker): introduce experimental task tracker feature by @anj-s in - [#24556](https://github.com/google-gemini/gemini-cli/pull/24556) -- docs(cli): fix inconsistent system.md casing in system prompt docs by @Bodlux - in [#25414](https://github.com/google-gemini/gemini-cli/pull/25414) -- feat(cli): add streamlined `gemini gemma` local model setup by @Samee24 in - [#25498](https://github.com/google-gemini/gemini-cli/pull/25498) -- Changelog for v0.38.2 by @gemini-cli-robot in - [#25593](https://github.com/google-gemini/gemini-cli/pull/25593) -- Fix: Disallow overriding IDE stdio via workspace .env (RCE) by @M0nd0R in - [#25022](https://github.com/google-gemini/gemini-cli/pull/25022) -- feat(test): refactor the memory usage test to use metrics from CLI process - instead of test runner by @cynthialong0-0 in - [#25708](https://github.com/google-gemini/gemini-cli/pull/25708) -- feat(vertex): add settings for Vertex AI request routing by @gordonhwc in - [#25513](https://github.com/google-gemini/gemini-cli/pull/25513) -- Fix/allow for session persistence by @ahsanfarooq210 in - [#25176](https://github.com/google-gemini/gemini-cli/pull/25176) -- Allow dots on GEMINI_API_KEY by @DKbyo in - [#25497](https://github.com/google-gemini/gemini-cli/pull/25497) -- feat(telemetry): add flag for enabling traces specifically by @spencer426 in - [#25343](https://github.com/google-gemini/gemini-cli/pull/25343) -- fix(core): resolve nested plan directory duplication and relative path - policies by @mahimashanware in - [#25138](https://github.com/google-gemini/gemini-cli/pull/25138) -- feat: detect new files in @ recommendations with watcher based updates by - @prassamin in [#25256](https://github.com/google-gemini/gemini-cli/pull/25256) -- fix(cli): use newline in shell command wrapping to avoid breaking heredocs by + [#25873](https://github.com/google-gemini/gemini-cli/pull/25873) +- fix(cli): add missing response key to custom theme text schema by @gaurav0107 + in [#25822](https://github.com/google-gemini/gemini-cli/pull/25822) +- fix(cli): provide manual update command when automatic update fails by @cocosheng-g in - [#25537](https://github.com/google-gemini/gemini-cli/pull/25537) -- fix(cli): ensure theme dialog labels are rendered for all themes by - @JayadityaGit in - [#24599](https://github.com/google-gemini/gemini-cli/pull/24599) -- fix(core): disable detached mode in Bun to prevent immediate SIGHUP of child - processes by @euxaristia in - [#22620](https://github.com/google-gemini/gemini-cli/pull/22620) -- feat: add /new as alias for /clear and refine command description by @ved015 - in [#17865](https://github.com/google-gemini/gemini-cli/pull/17865) -- fix(cli): start auto memory in ACP sessions by @jasonmatthewsuhari in - [#25626](https://github.com/google-gemini/gemini-cli/pull/25626) -- fix(core): remove duplicate initialize call on agents refreshed by - @adamfweidman in - [#25670](https://github.com/google-gemini/gemini-cli/pull/25670) -- test(e2e): default integration tests to Flash Preview by @SandyTao520 in - [#25753](https://github.com/google-gemini/gemini-cli/pull/25753) -- refactor(memory): replace MemoryManagerAgent with prompt-driven memory editing - across four tiers by @SandyTao520 in - [#25716](https://github.com/google-gemini/gemini-cli/pull/25716) -- fix(cli): fix "/clear (new)" command by @mini2s in - [#25801](https://github.com/google-gemini/gemini-cli/pull/25801) -- fix(core): use dynamic CLI version for IDE client instead of hardcoded '1.0.0' - by @thekishandev in - [#24414](https://github.com/google-gemini/gemini-cli/pull/24414) -- fix(core): handle line endings in ignore file parsing by @xoma-zver in - [#23895](https://github.com/google-gemini/gemini-cli/pull/23895) -- Fix/command injection shell by @Famous077 in - [#24170](https://github.com/google-gemini/gemini-cli/pull/24170) -- fix(ui): removed background color for input by @devr0306 in - [#25339](https://github.com/google-gemini/gemini-cli/pull/25339) -- fix(devtools): reduce memory usage and defer connection by @SandyTao520 in - [#24496](https://github.com/google-gemini/gemini-cli/pull/24496) -- fix(core): support jsonl session logs in memory and summary services by - @SandyTao520 in - [#25816](https://github.com/google-gemini/gemini-cli/pull/25816) -- fix(release): exclude ripgrep binaries from npm tarballs by @SandyTao520 in - [#25841](https://github.com/google-gemini/gemini-cli/pull/25841) -- fix(patch): cherry-pick 048bf6e to release/v0.40.0-preview.3-pr-25941 to patch - version v0.40.0-preview.3 and create version 0.40.0-preview.4 by + [#26052](https://github.com/google-gemini/gemini-cli/pull/26052) +- test(cli): add unit tests for restore ACP command (#23402) by @cocosheng-g in + [#26053](https://github.com/google-gemini/gemini-cli/pull/26053) +- fix(ui): better error messages for ECONNRESET and ETIMEDOUT by @devr0306 in + [#26059](https://github.com/google-gemini/gemini-cli/pull/26059) +- feat(core): wire up the new ContextManager and AgentChatHistory by @joshualitt + in [#25409](https://github.com/google-gemini/gemini-cli/pull/25409) +- fix(cli): ensure sandbox proxy cleanup and remove handler leaks by @ehedlund + in [#26065](https://github.com/google-gemini/gemini-cli/pull/26065) +- fix(cli): correct alternate buffer warning logic for JetBrains by @Adib234 in + [#26067](https://github.com/google-gemini/gemini-cli/pull/26067) +- fix(cli): make MCP ping optional in list command and use configured timeout by + @cocosheng-g in + [#26068](https://github.com/google-gemini/gemini-cli/pull/26068) +- fix(core): better error message for failed cloudshell-gca auth by @devr0306 in + [#26079](https://github.com/google-gemini/gemini-cli/pull/26079) +- feat(cli): provide manual session UUID via command line arg by @cocosheng-g in + [#26060](https://github.com/google-gemini/gemini-cli/pull/26060) +- Changelog for v0.40.0-preview.2 by @gemini-cli-robot in + [#25846](https://github.com/google-gemini/gemini-cli/pull/25846) +- (docs) update sandboxing documentation by @g-samroberts in + [#25930](https://github.com/google-gemini/gemini-cli/pull/25930) +- fix(core): enforce parallel task tracker updates by @anj-s in + [#24477](https://github.com/google-gemini/gemini-cli/pull/24477) +- Update policy so transient errors are not marked terminal by @DavidAPierce in + [#26066](https://github.com/google-gemini/gemini-cli/pull/26066) +- Implement bot that performs time-series metric analysis and suggests repo + management improvements by @gundermanc in + [#25945](https://github.com/google-gemini/gemini-cli/pull/25945) +- fix(core): handle non-string model flags in resolution by @Adib234 in + [#26069](https://github.com/google-gemini/gemini-cli/pull/26069) +- fix(ux): added error message for ENOTDIR by @devr0306 in + [#26128](https://github.com/google-gemini/gemini-cli/pull/26128) +- Changelog for v0.40.0-preview.3 by @gemini-cli-robot in + [#25904](https://github.com/google-gemini/gemini-cli/pull/25904) +- fix(cli): prevent ACP stdout pollution from SessionEnd hooks by @cocosheng-g + in [#26125](https://github.com/google-gemini/gemini-cli/pull/26125) +- feat(cli): support boolean and number casting for env vars in settings.json by + @cocosheng-g in + [#26118](https://github.com/google-gemini/gemini-cli/pull/26118) +- fix(cli): preserve Request headers in DevTools activity logger by @Adib234 in + [#26078](https://github.com/google-gemini/gemini-cli/pull/26078) +- fix(patch): cherry-pick 2194da2 to release/v0.41.0-preview.0-pr-26153 to patch + version v0.41.0-preview.0 and create version 0.41.0-preview.1 by @gemini-cli-robot in - [#25942](https://github.com/google-gemini/gemini-cli/pull/25942) -- fix(patch): cherry-pick 54b7586 to release/v0.40.0-preview.4-pr-26066 - [CONFLICTS] by @gemini-cli-robot in - [#26124](https://github.com/google-gemini/gemini-cli/pull/26124) + [#26269](https://github.com/google-gemini/gemini-cli/pull/26269) +- fix(patch): cherry-pick 1d72a12 to release/v0.41.0-preview.1-pr-26479 to patch + version v0.41.0-preview.1 and create version 0.41.0-preview.2 by + @gemini-cli-robot in + [#26508](https://github.com/google-gemini/gemini-cli/pull/26508) +- fix(patch): cherry-pick 7cc19c2 to release/v0.41.0-preview.2-pr-26507 to patch + version v0.41.0-preview.2 and create version 0.41.0-preview.3 by + @gemini-cli-robot in + [#26530](https://github.com/google-gemini/gemini-cli/pull/26530) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.39.1...v0.40.0 +https://github.com/google-gemini/gemini-cli/compare/v0.40.1...v0.41.0 diff --git a/docs/extensions/releasing.md b/docs/extensions/releasing.md index 10ab3584ed..9746af86c1 100644 --- a/docs/extensions/releasing.md +++ b/docs/extensions/releasing.md @@ -1,7 +1,8 @@ # Release extensions Release Gemini CLI extensions to your users through a Git repository or GitHub -Releases. +Releases. This guide explains how to share your work, list it in the gallery, +and manage updates. Git repository releases are the simplest approach and offer the most flexibility for managing development branches. GitHub Releases are more efficient for @@ -153,29 +154,62 @@ jobs: release/win32.arm64.my-tool.zip ``` -## Migrating an Extension Repository +## Migrate an extension repository -If you need to move your extension to a new repository (for example, from a -personal account to an organization) or rename it, you can use the `migratedTo` -property in your `gemini-extension.json` file to seamlessly transition your +If you move your extension to a new repository or rename it, use the +`migratedTo` property in `gemini-extension.json` to seamlessly transition your users. -1. **Create the new repository**: Setup your extension in its new location. -2. **Update the old repository**: In your original repository, update the - `gemini-extension.json` file to include the `migratedTo` property, pointing - to the new repository URL, and bump the version number. You can optionally - change the `name` of your extension at this time in the new repository. - ```json - { - "name": "my-extension", - "version": "1.1.0", - "migratedTo": "https://github.com/new-owner/new-extension-repo" - } - ``` -3. **Release the update**: Publish this new version in your old repository. +1. **Create the new repository:** Set up your extension in its new location. +2. **Update the old repository:** In your original repository, update the + `gemini-extension.json` file to include the `migratedTo` property pointing + to the new repository URL, and increment the version number. + ```json + { + "name": "my-extension", + "version": "1.1.0", + "migratedTo": "https://github.com/new-owner/new-extension-repo" + } + ``` +3. **Release the update:** Publish this new version in your old repository. -When users check for updates, Gemini CLI will detect the `migratedTo` field, -verify that the new repository contains a valid extension update, and -automatically update their local installation to track the new source and name -moving forward. All extension settings will automatically migrate to the new -installation. +When users check for updates, Gemini CLI detects the `migratedTo` field, +verifies the new repository, and automatically updates their local installation +to track the new source. All settings migrate automatically. + +## How updates work + +Gemini CLI automatically checks for extension updates based on the installation +method. Understanding these mechanisms helps you ensure your users always have +the latest version. + +### Sync manifest and tags + +For GitHub releases, always ensure the `version` in `gemini-extension.json` +matches your GitHub release tag. While the CLI uses tags for update detection, +it displays the manifest version in the UI. Keeping them in sync prevents +confusion. + +### Update mechanisms + +
+Technical update details + +The CLI uses different strategies depending on the installation type: + +- **GitHub releases:** The CLI queries the GitHub API for the latest release + tag. It ignores the `version` field in the manifest for detection. +- **Git clones:** The CLI runs `git ls-remote` to compare the latest remote + commit hash with your local `HEAD`. +- **Local extensions:** The CLI compares the `version` field in the source + directory's manifest with the installed version. + +To verify an extension's installation type, inspect the `type` field in the +metadata file at `~/.gemini/extensions//.gemini-extension-install.json`. + +
+ + +> [!IMPORTANT] +> The `migratedTo` flow requires at least one release on the new repository for +> the CLI to recognize it as a valid update source. diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx index 3902f918f7..9de9e1385b 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx @@ -13,6 +13,7 @@ import { type SessionMetrics } from '../contexts/SessionContext.js'; import { ToolCallDecision, getShellConfiguration, + isWindows, type WorktreeSettings, } from '@google/gemini-cli-core'; @@ -22,6 +23,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...actual, getShellConfiguration: vi.fn(), + isWindows: vi.fn(), }; }); @@ -44,6 +46,7 @@ vi.mock('../contexts/ConfigContext.js', async (importOriginal) => { }); const getShellConfigurationMock = vi.mocked(getShellConfiguration); +const isWindowsMock = vi.mocked(isWindows); const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats); const renderWithMockedStats = async ( @@ -106,6 +109,7 @@ describe('', () => { argsPrefix: ['-c'], shell: 'bash', }); + isWindowsMock.mockReturnValue(false); }); it('renders the summary display with a title', async () => { @@ -149,7 +153,7 @@ describe('', () => { ); const output = lastFrame(); - // Standard UUID characters should not be escaped/quoted by default for bash. + // Standard UUID characters are NOT wrapped in double quotes on non-Windows. expect(output).toContain('gemini --resume 1234-abcd-5678-efgh'); unmount(); }); @@ -167,7 +171,8 @@ describe('', () => { unmount(); }); - it('renders a standard UUID-formatted session ID in the footer (powershell)', async () => { + it('renders a standard UUID-formatted session ID in the footer (powershell) on Windows', async () => { + isWindowsMock.mockReturnValue(true); getShellConfigurationMock.mockReturnValue({ executable: 'powershell.exe', argsPrefix: ['-NoProfile', '-Command'], @@ -181,9 +186,8 @@ describe('', () => { ); const output = lastFrame(); - // PowerShell doesn't wraps UUID in single quotes because - // it contains no special characters. - expect(output).toContain('gemini --resume 1234-abcd-5678-efgh'); + // PowerShell doesn't wrap UUID in quotes by default, but we wrap it in double quotes on Windows. + expect(output).toContain('gemini --resume "1234-abcd-5678-efgh"'); unmount(); }); @@ -201,7 +205,8 @@ describe('', () => { ); const output = lastFrame(); - // PowerShell wraps in single quotes and escapes internal single quotes by doubling them + // PowerShell wraps in single quotes and escapes internal single quotes by doubling them. + // Since it's already quoted, we don't add redundant double quotes. expect(output).toContain("gemini --resume '''; rm -rf / #'"); unmount(); }); diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx index 7313949a9c..55ef50c746 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx @@ -8,7 +8,11 @@ import type React from 'react'; import { StatsDisplay } from './StatsDisplay.js'; import { useSessionStats } from '../contexts/SessionContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; -import { escapeShellArg, getShellConfiguration } from '@google/gemini-cli-core'; +import { + escapeShellArg, + getShellConfiguration, + isWindows, +} from '@google/gemini-cli-core'; interface SessionSummaryDisplayProps { duration: string; @@ -24,11 +28,17 @@ export const SessionSummaryDisplay: React.FC = ({ const worktreeSettings = config.getWorktreeSettings(); const escapedSessionId = escapeShellArg(stats.sessionId, shell); - let footer = `To resume this session: gemini --resume ${escapedSessionId}`; + const footerSessionId = + isWindows() && + !escapedSessionId.startsWith('"') && + !escapedSessionId.startsWith("'") + ? `"${escapedSessionId}"` + : escapedSessionId; + let footer = `To resume this session: gemini --resume ${footerSessionId}`; if (worktreeSettings) { footer = - `To resume work in this worktree: cd ${escapeShellArg(worktreeSettings.path, shell)} && gemini --resume ${escapedSessionId}\n` + + `To resume work in this worktree: cd ${escapeShellArg(worktreeSettings.path, shell)} && gemini --resume ${footerSessionId}\n` + `To remove manually: git worktree remove ${escapeShellArg(worktreeSettings.path, shell)}`; } diff --git a/packages/core/src/agent/content-utils.test.ts b/packages/core/src/agent/content-utils.test.ts index 7de54c56fa..acf8a4a329 100644 --- a/packages/core/src/agent/content-utils.test.ts +++ b/packages/core/src/agent/content-utils.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { geminiPartsToContentParts, contentPartsToGeminiParts, @@ -12,6 +12,7 @@ import { } from './content-utils.js'; import type { Part } from '@google/genai'; import type { ContentPart } from './types.js'; +import { debugLogger } from '../utils/debugLogger.js'; describe('geminiPartsToContentParts', () => { it('converts text parts', () => { @@ -191,11 +192,17 @@ describe('contentPartsToGeminiParts', () => { const content = [ { type: 'custom_widget', payload: 123 }, ] as unknown as ContentPart[]; + + const warnSpy = vi.spyOn(debugLogger, 'warn'); const result = contentPartsToGeminiParts(content); + + expect(warnSpy).toHaveBeenCalled(); expect(result).toHaveLength(1); expect(result[0]).toEqual({ text: JSON.stringify({ type: 'custom_widget', payload: 123 }), }); + + warnSpy.mockRestore(); }); }); diff --git a/packages/core/src/agent/content-utils.ts b/packages/core/src/agent/content-utils.ts index aaf191fe8e..42b0b7fec7 100644 --- a/packages/core/src/agent/content-utils.ts +++ b/packages/core/src/agent/content-utils.ts @@ -6,6 +6,7 @@ import type { Part } from '@google/genai'; import type { ContentPart } from './types.js'; +import { debugLogger } from '../utils/debugLogger.js'; /** * Converts Gemini API Part objects to framework-agnostic ContentPart objects. @@ -93,6 +94,9 @@ export function contentPartsToGeminiParts(content: ContentPart[]): Part[] { result.push({ text: part.text }); break; default: + debugLogger.warn( + `Unhandled ContentPart type: ${JSON.stringify(part)} fallback to serialization`, + ); // Serialize unknown ContentPart variants instead of dropping them result.push({ text: JSON.stringify(part) }); break; diff --git a/packages/core/src/agent/legacy-agent-session.test.ts b/packages/core/src/agent/legacy-agent-session.test.ts index db3c173983..525548e292 100644 --- a/packages/core/src/agent/legacy-agent-session.test.ts +++ b/packages/core/src/agent/legacy-agent-session.test.ts @@ -1330,6 +1330,7 @@ describe('LegacyAgentSession', () => { ); expect(err?.message).toBe('Connection refused'); expect(err?.fatal).toBe(true); + expect(err?._meta?.['stack']).toBeDefined(); const streamEnd = events.find( (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end', diff --git a/packages/core/src/agent/legacy-agent-session.ts b/packages/core/src/agent/legacy-agent-session.ts index d65c583b0b..e8d5e56ef5 100644 --- a/packages/core/src/agent/legacy-agent-session.ts +++ b/packages/core/src/agent/legacy-agent-session.ts @@ -166,6 +166,7 @@ export class LegacyAgentProtocol implements AgentProtocol { } else { this._emitErrorAndAgentEnd(err); } + } finally { this._clearActiveStream(); } } @@ -390,6 +391,7 @@ export class LegacyAgentProtocol implements AgentProtocol { const meta: Record = {}; if (err instanceof Error) { meta['errorName'] = err.constructor.name; + meta['stack'] = err.stack; if ('exitCode' in err && typeof err.exitCode === 'number') { meta['exitCode'] = err.exitCode; } diff --git a/packages/core/src/context/contextManager.barrier.test.ts b/packages/core/src/context/contextManager.barrier.test.ts index c3a7298ddc..438f9d3230 100644 --- a/packages/core/src/context/contextManager.barrier.test.ts +++ b/packages/core/src/context/contextManager.barrier.test.ts @@ -60,9 +60,7 @@ describe('ContextManager Sync Pressure Barrier Tests', () => { // Verify Episode 0 (System) was pruned, so we now start with a sentinel due to role alternation expect(projection[0].role).toBe('user'); - expect(projection[0].parts![0].text).toBe( - '[Continuing from previous AI thoughts...]', - ); + expect(projection[0].parts![0].text).toContain('User turn 17'); // Filter out synthetic Yield nodes (they are model responses without actual tool/text bodies) const contentNodes = projection.filter( diff --git a/packages/core/src/context/contextManager.ts b/packages/core/src/context/contextManager.ts index 48e8dcd88b..e949090cc1 100644 --- a/packages/core/src/context/contextManager.ts +++ b/packages/core/src/context/contextManager.ts @@ -72,7 +72,11 @@ export class ContextManager { event.targets, event.returnedNodes, ); - this.evaluateTriggers(new Set()); + // We explicitly DO NOT call evaluateTriggers here. + // The Context Manager is a one-way assembly line. It only evaluates triggers + // when fundamentally new organic context is added via PristineHistoryUpdated. + // Re-evaluating after a processor finishes creates infinite feedback loops if + // the processor fails to reduce the token count below the threshold. }); this.historyObserver.start(); @@ -126,10 +130,15 @@ export class ContextManager { // Walk backwards finding nodes that fall out of the retained budget for (let i = this.buffer.nodes.length - 1; i >= 0; i--) { const node = this.buffer.nodes[i]; + const priorTokens = rollingTokens; rollingTokens += this.env.tokenCalculator.calculateConcreteListTokens([ node, ]); - if (rollingTokens > this.sidecar.config.budget.retainedTokens) { + + // Loose Boundary Policy: If this node is the one that pushes us over the retained limit, + // we KEEP it to prevent aggressive undershooting. We only age out nodes that are + // strictly *older* than the boundary node. + if (priorTokens > this.sidecar.config.budget.retainedTokens) { // Only age out if not protected if (!protectedIds.has(node.id)) { agedOutNodes.add(node.id); diff --git a/packages/core/src/context/graph/render.test.ts b/packages/core/src/context/graph/render.test.ts index 22d625695a..e3890ae437 100644 --- a/packages/core/src/context/graph/render.test.ts +++ b/packages/core/src/context/graph/render.test.ts @@ -61,4 +61,169 @@ describe('render', () => { expect(result.history).toEqual([{ text: '1' }, { text: '2' }]); }); + + it('simulates the boundary knapsack problem (loose boundary policy)', async () => { + // 10k, 20k, 40k, 5k + const mockNodes: ConcreteNode[] = [ + { + id: 'D', + type: NodeType.USER_PROMPT, + payload: {} as Part, + } as unknown as ConcreteNode, + { + id: 'C', + type: NodeType.AGENT_THOUGHT, + payload: {} as Part, + } as unknown as ConcreteNode, + { + id: 'B', + type: NodeType.USER_PROMPT, + payload: {} as Part, + } as unknown as ConcreteNode, + { + id: 'A', + type: NodeType.AGENT_THOUGHT, + payload: {} as Part, + } as unknown as ConcreteNode, + ]; + + const tokenMap: Record = { + D: 5000, + C: 40000, + B: 20000, + A: 10000, + }; + + const orchestrator = { + executeTriggerSync: vi.fn(async (trigger, nodes, agedOutNodes) => + nodes.filter((n: ConcreteNode) => !agedOutNodes.has(n.id)), + ), + } as unknown as PipelineOrchestrator; + + const sidecar = { + config: { + budget: { maxTokens: 150000, retainedTokens: 65000 }, + }, + } as unknown as ContextProfile; + + const currentTokens = 160000; + + const env = { + llmClient: { + countTokens: vi.fn().mockResolvedValue({ totalTokens: 1000 }), + }, + tokenCalculator: { + calculateConcreteListTokens: vi.fn((nodes) => { + if (nodes.length === 1) return tokenMap[nodes[0].id]; + return currentTokens; + }), + calculateTokenBreakdown: vi.fn(() => ({})), + }, + graphMapper: { + fromGraph: vi.fn((nodes: readonly ConcreteNode[]) => + nodes.map((n) => ({ text: n.id })), + ), + }, + } as unknown as ContextEnvironment; + + const tracer = { + logEvent: vi.fn(), + } as unknown as ContextTracer; + + const result = await render( + mockNodes, + orchestrator, + sidecar, + tracer, + env, + new Map(), + 0, + new Set(), + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const surviving = result.history.map((c: any) => c.text); + // Loose Boundary: A (10k), B (20k), C (40k). Total = 70k. + // Adding C pushes rolling total (70k) above retainedTokens (65k). + // Under loose policy, C survives. D is strictly older and drops. + expect(surviving).toEqual(['C', 'B', 'A']); // D is dropped + }); + + it('drops nodes that are STRICTLY older than the boundary node', async () => { + const mockNodes: ConcreteNode[] = [ + { + id: 'A', + type: NodeType.USER_PROMPT, + payload: {} as Part, + } as unknown as ConcreteNode, + { + id: 'B', + type: NodeType.AGENT_THOUGHT, + payload: {} as Part, + } as unknown as ConcreteNode, + { + id: 'C', + type: NodeType.USER_PROMPT, + payload: {} as Part, + } as unknown as ConcreteNode, + ]; + + const tokenMap: Record = { + C: 40000, + B: 40000, + A: 10000, + }; + + const orchestrator = { + executeTriggerSync: vi.fn(async (trigger, nodes, agedOutNodes) => + nodes.filter((n: ConcreteNode) => !agedOutNodes.has(n.id)), + ), + } as unknown as PipelineOrchestrator; + + const sidecar = { + config: { + budget: { maxTokens: 150000, retainedTokens: 65000 }, + }, + } as unknown as ContextProfile; + + const currentTokens = 160000; + + const env = { + llmClient: { + countTokens: vi.fn().mockResolvedValue({ totalTokens: 1000 }), + }, + tokenCalculator: { + calculateConcreteListTokens: vi.fn((nodes) => { + if (nodes.length === 1) return tokenMap[nodes[0].id]; + return currentTokens; + }), + calculateTokenBreakdown: vi.fn(() => ({})), + }, + graphMapper: { + fromGraph: vi.fn((nodes: readonly ConcreteNode[]) => + nodes.map((n) => ({ text: n.id })), + ), + }, + } as unknown as ContextEnvironment; + + const tracer = { + logEvent: vi.fn(), + } as unknown as ContextTracer; + + const result = await render( + mockNodes, + orchestrator, + sidecar, + tracer, + env, + new Map(), + 0, + new Set(), + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const surviving = result.history.map((c: any) => c.text); + // C(40k), B(40k). Adding B pushes total to 80k. B is the boundary node and survives. A drops. + expect(surviving).toEqual(['B', 'C']); // A is dropped + }); }); diff --git a/packages/core/src/context/graph/render.ts b/packages/core/src/context/graph/render.ts index b4ce596dec..5c0fa3df0e 100644 --- a/packages/core/src/context/graph/render.ts +++ b/packages/core/src/context/graph/render.ts @@ -10,6 +10,7 @@ import type { ContextTracer } from '../tracer.js'; import type { ContextProfile } from '../config/profiles.js'; import type { PipelineOrchestrator } from '../pipeline/orchestrator.js'; import type { ContextEnvironment } from '../pipeline/environment.js'; +import { performCalibration } from '../utils/tokenCalibration.js'; /** * Maps the Episodic Context Graph back into a raw Gemini Content[] array for transmission. @@ -68,6 +69,7 @@ export async function render( tracer.logEvent('Render', 'Render Context for LLM', { renderedContext: contents, }); + performCalibration(env, visibleNodes, contents); return { history: contents, didApplyManagement: false }; } const targetDelta = currentTokens - sidecar.config.budget.retainedTokens; @@ -83,9 +85,12 @@ export async function render( // Start from newest and count backwards for (let i = nodes.length - 1; i >= 0; i--) { const node = nodes[i]; + const priorTokens = rollingTokens; const nodeTokens = env.tokenCalculator.calculateConcreteListTokens([node]); rollingTokens += nodeTokens; - if (rollingTokens > sidecar.config.budget.retainedTokens) { + + // Loose Boundary Policy: Keep the node that crosses the boundary + if (priorTokens > sidecar.config.budget.retainedTokens) { agedOutNodes.add(node.id); } } @@ -113,5 +118,6 @@ export async function render( tracer.logEvent('Render', 'Render Sanitized Context for LLM', { renderedContextSanitized: contents, }); + performCalibration(env, visibleNodes, contents); return { history: contents, didApplyManagement: true }; } diff --git a/packages/core/src/context/initializer.ts b/packages/core/src/context/initializer.ts index cffaae20b7..3b37d2bac7 100644 --- a/packages/core/src/context/initializer.ts +++ b/packages/core/src/context/initializer.ts @@ -94,6 +94,10 @@ export async function initializeContextManager( tracer, 4, eventBus, + { + calibrateTokenCalculation: + !!process.env['GEMINI_CONTEXT_CALIBRATE_TOKEN_CALCULATIONS'], + }, ); const orchestrator = new PipelineOrchestrator( diff --git a/packages/core/src/context/pipeline/environment.ts b/packages/core/src/context/pipeline/environment.ts index 92c0173e92..b57466638a 100644 --- a/packages/core/src/context/pipeline/environment.ts +++ b/packages/core/src/context/pipeline/environment.ts @@ -13,6 +13,10 @@ import type { ContextGraphMapper } from '../graph/mapper.js'; export type { ContextTracer, ContextEventBus }; +export interface RenderOptions { + calibrateTokenCalculation?: boolean; +} + export interface ContextEnvironment { readonly llmClient: BaseLlmClient; readonly promptId: string; @@ -26,4 +30,5 @@ export interface ContextEnvironment { readonly inbox: LiveInbox; readonly behaviorRegistry: NodeBehaviorRegistry; readonly graphMapper: ContextGraphMapper; + readonly renderOptions?: RenderOptions; } diff --git a/packages/core/src/context/pipeline/environmentImpl.ts b/packages/core/src/context/pipeline/environmentImpl.ts index 67f45aaa7b..736792d561 100644 --- a/packages/core/src/context/pipeline/environmentImpl.ts +++ b/packages/core/src/context/pipeline/environmentImpl.ts @@ -6,7 +6,7 @@ import type { BaseLlmClient } from '../../core/baseLlmClient.js'; import type { ContextTracer } from '../tracer.js'; -import type { ContextEnvironment } from './environment.js'; +import type { ContextEnvironment, RenderOptions } from './environment.js'; import type { ContextEventBus } from '../eventBus.js'; import { ContextTokenCalculator } from '../utils/contextTokenCalculator.js'; import { LiveInbox } from './inbox.js'; @@ -29,6 +29,7 @@ export class ContextEnvironmentImpl implements ContextEnvironment { readonly tracer: ContextTracer, readonly charsPerToken: number, readonly eventBus: ContextEventBus, + readonly renderOptions?: RenderOptions, ) { this.behaviorRegistry = new NodeBehaviorRegistry(); registerBuiltInBehaviors(this.behaviorRegistry); diff --git a/packages/core/src/context/system-tests/__snapshots__/lifecycle.golden.test.ts.snap b/packages/core/src/context/system-tests/__snapshots__/lifecycle.golden.test.ts.snap index 66bf020f8e..201fbac191 100644 --- a/packages/core/src/context/system-tests/__snapshots__/lifecycle.golden.test.ts.snap +++ b/packages/core/src/context/system-tests/__snapshots__/lifecycle.golden.test.ts.snap @@ -6,7 +6,87 @@ exports[`System Lifecycle Golden Tests > Scenario 1: Organic Growth with Huge To { "parts": [ { - "text": "[Continuing from previous AI thoughts...]", + "text": "System Instructions", + }, + ], + "role": "user", + }, + { + "parts": [ + { + "text": "Ack.", + }, + ], + "role": "model", + }, + { + "parts": [ + { + "text": "Hello!", + }, + ], + "role": "user", + }, + { + "parts": [ + { + "text": "Hi, how can I help?", + }, + ], + "role": "model", + }, + { + "parts": [ + { + "text": "Read the logs.", + }, + ], + "role": "user", + }, + { + "parts": [ + { + "functionCall": { + "args": { + "cmd": "cat server.log", + }, + "name": "run_shell_command", + }, + "thoughtSignature": "skip_thought_signature_validator", + }, + ], + "role": "model", + }, + { + "parts": [ + { + "functionResponse": { + "name": "run_shell_command", + "response": { + "output": " +[Tool observation string (0.02MB, 1 lines) masked to preserve context window. Full string saved to: ] +", + }, + }, + }, + ], + "role": "user", + }, + { + "parts": [ + { + "text": "The logs are very long.", + }, + ], + "role": "model", + }, + { + "parts": [ + { + "text": "Look at this architecture diagram:", + }, + { + "text": "[Multi-Modal Blob (image/png, 0.01MB) degraded to text to preserve context window. Saved to: ]", }, ], "role": "user", @@ -38,10 +118,7 @@ exports[`System Lifecycle Golden Tests > Scenario 1: Organic Growth with Huge To { "parts": [ { - "text": "[Multi-Modal Blob (image/png, 0.01MB) degraded to text to preserve context window. Saved to: ]", - }, - { - "text": "", + "text": "Please continue.", }, ], "role": "user", @@ -59,18 +136,18 @@ exports[`System Lifecycle Golden Tests > Scenario 1: Organic Growth with Huge To "turnIndex": 1, }, { - "tokensAfterBackground": 327, + "tokensAfterBackground": 437, "tokensBeforeBackground": 20172, "turnIndex": 2, }, { - "tokensAfterBackground": 393, - "tokensBeforeBackground": 23197, + "tokensAfterBackground": 526, + "tokensBeforeBackground": 3462, "turnIndex": 3, }, { - "tokensAfterBackground": 411, - "tokensBeforeBackground": 23215, + "tokensAfterBackground": 544, + "tokensBeforeBackground": 544, "turnIndex": 4, }, ], @@ -136,13 +213,13 @@ exports[`System Lifecycle Golden Tests > Scenario 2: Under Budget (No Modificati } `; -exports[`System Lifecycle Golden Tests > Scenario 3: Async-Driven Background GC 1`] = ` +exports[`System Lifecycle Golden Tests > Scenario 3: Node Distillation of Large Historical Messages 1`] = ` { "finalProjection": [ { "parts": [ { - "text": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "text": "Mock response from: utility_compressor, for: {"text":"A...AAAAAAAA"}", }, ], "role": "user", @@ -150,7 +227,7 @@ exports[`System Lifecycle Golden Tests > Scenario 3: Async-Driven Background GC { "parts": [ { - "text": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "text": "Mock response from: utility_compressor, for: {"text":"B...BBBBBBBB"}", }, ], "role": "model", @@ -158,7 +235,7 @@ exports[`System Lifecycle Golden Tests > Scenario 3: Async-Driven Background GC { "parts": [ { - "text": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + "text": "Mock response from: utility_compressor, for: {"text":"C...CCCCCCCC"}", }, ], "role": "user", @@ -166,7 +243,7 @@ exports[`System Lifecycle Golden Tests > Scenario 3: Async-Driven Background GC { "parts": [ { - "text": "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", + "text": "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", }, ], "role": "model", @@ -174,7 +251,7 @@ exports[`System Lifecycle Golden Tests > Scenario 3: Async-Driven Background GC { "parts": [ { - "text": "EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE", + "text": "EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE", }, ], "role": "user", @@ -182,7 +259,7 @@ exports[`System Lifecycle Golden Tests > Scenario 3: Async-Driven Background GC { "parts": [ { - "text": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", + "text": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", }, ], "role": "model", @@ -198,20 +275,113 @@ exports[`System Lifecycle Golden Tests > Scenario 3: Async-Driven Background GC ], "tokenTrajectory": [ { - "tokensAfterBackground": 42, - "tokensBeforeBackground": 42, + "tokensAfterBackground": 3308, + "tokensBeforeBackground": 3308, "turnIndex": 0, }, { - "tokensAfterBackground": 84, - "tokensBeforeBackground": 84, + "tokensAfterBackground": 4989, + "tokensBeforeBackground": 6616, "turnIndex": 1, }, { - "tokensAfterBackground": 126, - "tokensBeforeBackground": 126, + "tokensAfterBackground": 5043, + "tokensBeforeBackground": 8297, "turnIndex": 2, }, ], } `; + +exports[`System Lifecycle Golden Tests > Scenario 4: Async-Driven Background GC via State Snapshots 1`] = ` +{ + "finalProjection": [ + { + "parts": [ + { + "text": "Mock response from: utility_state_snapshot_processor, for: {"text":"T.........\\n"}", + }, + { + "text": "Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 Msg 4 ..................................................", + }, + ], + "role": "user", + }, + { + "parts": [ + { + "text": "Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 Msg 5 ..................................................", + }, + ], + "role": "model", + }, + { + "parts": [ + { + "text": "Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 Msg 6 ..................................................", + }, + ], + "role": "user", + }, + { + "parts": [ + { + "text": "Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 Msg 7 ..................................................", + }, + ], + "role": "model", + }, + { + "parts": [ + { + "text": "Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 ..................................................", + }, + ], + "role": "user", + }, + { + "parts": [ + { + "text": "Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 ..................................................", + }, + ], + "role": "model", + }, + { + "parts": [ + { + "text": "Please continue.", + }, + ], + "role": "user", + }, + ], + "tokenTrajectory": [ + { + "tokensAfterBackground": 140, + "tokensBeforeBackground": 140, + "turnIndex": 0, + }, + { + "tokensAfterBackground": 280, + "tokensBeforeBackground": 280, + "turnIndex": 1, + }, + { + "tokensAfterBackground": 420, + "tokensBeforeBackground": 420, + "turnIndex": 2, + }, + { + "tokensAfterBackground": 560, + "tokensBeforeBackground": 560, + "turnIndex": 3, + }, + { + "tokensAfterBackground": 700, + "tokensBeforeBackground": 700, + "turnIndex": 4, + }, + ], +} +`; diff --git a/packages/core/src/context/system-tests/lifecycle.golden.test.ts b/packages/core/src/context/system-tests/lifecycle.golden.test.ts index 46f082e09c..9e6512e646 100644 --- a/packages/core/src/context/system-tests/lifecycle.golden.test.ts +++ b/packages/core/src/context/system-tests/lifecycle.golden.test.ts @@ -9,11 +9,7 @@ import fs from 'node:fs'; import { SimulationHarness } from './simulationHarness.js'; import { createMockLlmClient } from '../testing/contextTestUtils.js'; import type { ContextProfile } from '../config/profiles.js'; -import { createToolMaskingProcessor } from '../processors/toolMaskingProcessor.js'; -import { createBlobDegradationProcessor } from '../processors/blobDegradationProcessor.js'; -import { createStateSnapshotProcessor } from '../processors/stateSnapshotProcessor.js'; -import { createHistoryTruncationProcessor } from '../processors/historyTruncationProcessor.js'; -import { createStateSnapshotAsyncProcessor } from '../processors/stateSnapshotAsyncProcessor.js'; +import { stressTestProfile } from '../config/profiles.js'; expect.addSnapshotSerializer({ test: (val) => @@ -52,57 +48,22 @@ describe('System Lifecycle Golden Tests', () => { vi.restoreAllMocks(); }); - const getAggressiveConfig = (): ContextProfile => ({ - name: 'Aggressive Test', - config: { - budget: { maxTokens: 1000, retainedTokens: 500 }, // Extremely tight limits - }, - buildPipelines: (env) => [ - { - name: 'Pressure Relief', // Emits from eventBus 'retained_exceeded' - triggers: ['retained_exceeded'], - processors: [ - createBlobDegradationProcessor('BlobDegradationProcessor', env), - createToolMaskingProcessor('ToolMaskingProcessor', env, { - stringLengthThresholdTokens: 50, - }), - createStateSnapshotProcessor('StateSnapshotProcessor', env, {}), - ], - }, - { - name: 'Immediate Sanitization', // The magic string the projector is hardcoded to use - triggers: ['retained_exceeded'], - processors: [ - createHistoryTruncationProcessor( - 'HistoryTruncationProcessor', - env, - {}, - ), - ], - }, - ], - buildAsyncPipelines: (env) => [ - { - name: 'Async', - triggers: ['nodes_aged_out'], - processors: [ - createStateSnapshotAsyncProcessor( - 'StateSnapshotAsyncProcessor', - env, - {}, - ), - ], - }, - ], - }); - - const mockLlmClient = createMockLlmClient([ - '', - ]); + // Uses dynamic role-based mocking to differentiate Snapshot vs Distillation output automatically. + const mockLlmClient = createMockLlmClient(); it('Scenario 1: Organic Growth with Huge Tool Output & Images', async () => { + // Override stressTestProfile limits slightly to ensure immediate overflow + // without having to push 50,000 characters to cross the generalist boundaries. + const customProfile: ContextProfile = { + ...stressTestProfile, + config: { + ...stressTestProfile.config, + budget: { maxTokens: 1000, retainedTokens: 500 }, + }, + }; + const harness = await SimulationHarness.create( - getAggressiveConfig(), + customProfile, mockLlmClient, ); @@ -169,6 +130,9 @@ describe('System Lifecycle Golden Tests', () => { { role: 'model', parts: [{ text: 'Yes we can.' }] }, ]); + // Give the background tasks a moment to inject the snapshot into the graph + await new Promise((resolve) => setTimeout(resolve, 50)); + // Get final state const goldenState = await harness.getGoldenState(); @@ -212,54 +176,117 @@ describe('System Lifecycle Golden Tests', () => { expect(goldenState).toMatchSnapshot(); }); - it('Scenario 3: Async-Driven Background GC', async () => { - const gcConfig: ContextProfile = { - name: 'GC Test Config', + it('Scenario 3: Node Distillation of Large Historical Messages', async () => { + // 1 Turn = ~2520 tokens. + // retainedTokens = 4000 ensures Turn 0 is kept intact until Turn 1 pushes the total to ~5040. + const customProfile: ContextProfile = { + ...stressTestProfile, config: { - budget: { maxTokens: 200, retainedTokens: 100 }, - }, - buildPipelines: () => [], - buildAsyncPipelines: (env) => [ - { - name: 'Async', - triggers: ['nodes_aged_out'], - processors: [ - createStateSnapshotAsyncProcessor( - 'StateSnapshotAsyncProcessor', - env, - {}, - ), - ], + ...stressTestProfile.config, + budget: { maxTokens: 10000, retainedTokens: 4000 }, + processorOptions: { + ...stressTestProfile.config?.processorOptions, + NodeDistillation: { + type: 'NodeDistillationProcessor', + options: { + nodeThresholdTokens: 1000, // 1250 > 1000, so older messages will be distilled + }, + }, }, - ], + }, + // Disable async pipelines (StateSnapshots) so they don't compete with the Normalization pipeline + buildAsyncPipelines: () => [], }; - const harness = await SimulationHarness.create(gcConfig, mockLlmClient); + const harness = await SimulationHarness.create( + customProfile, + mockLlmClient, + ); // Turn 0 await harness.simulateTurn([ - { role: 'user', parts: [{ text: 'A'.repeat(50) }] }, - { role: 'model', parts: [{ text: 'B'.repeat(50) }] }, + { role: 'user', parts: [{ text: 'A'.repeat(5000) }] }, + { role: 'model', parts: [{ text: 'B'.repeat(5000) }] }, ]); - // Turn 1 (Should trigger StateSnapshotasync pipeline because we exceed 100 retainedTokens) + // Turn 1 await harness.simulateTurn([ - { role: 'user', parts: [{ text: 'C'.repeat(50) }] }, - { role: 'model', parts: [{ text: 'D'.repeat(50) }] }, + { role: 'user', parts: [{ text: 'C'.repeat(5000) }] }, + { role: 'model', parts: [{ text: 'D'.repeat(5000) }] }, ]); - // Give the async background pipeline an extra beat to complete its async execution and emit variants - await new Promise((resolve) => setTimeout(resolve, 50)); - // Turn 2 await harness.simulateTurn([ - { role: 'user', parts: [{ text: 'E'.repeat(50) }] }, - { role: 'model', parts: [{ text: 'F'.repeat(50) }] }, + { role: 'user', parts: [{ text: 'E'.repeat(5000) }] }, + { role: 'model', parts: [{ text: 'F'.repeat(5000) }] }, ]); const goldenState = await harness.getGoldenState(); - // We should see ROLLING_SUMMARY nodes injected into the graph, proving the async pipeline ran in the background + // We should see MOCKED_DISTILLED_NODE replacing older bloated messages, while recent messages are untouched. + expect(goldenState).toMatchSnapshot(); + }); + + it('Scenario 4: Async-Driven Background GC via State Snapshots', async () => { + // Mathematical Token Budgeting: + // 200 chars ≈ 50 tokens. + // 1 Turn (User + Model + Overhead) ≈ 50 + 50 + 20 = 120 Tokens. + const customProfile: ContextProfile = { + ...stressTestProfile, + config: { + ...stressTestProfile.config, + // Retain 3 Turns (~360 tokens). Max 5 Turns (~600 tokens). + budget: { maxTokens: 600, retainedTokens: 360 }, + }, + }; + + const harness = await SimulationHarness.create( + customProfile, + mockLlmClient, + ); + + const createMessage = (index: number) => + `Msg ${index} `.repeat(25).padEnd(200, '.'); + + // Turn 0 (~120 tokens) Total: 120 + await harness.simulateTurn([ + { role: 'user', parts: [{ text: createMessage(0) }] }, + { role: 'model', parts: [{ text: createMessage(1) }] }, + ]); + + // Turn 1 (~120 tokens) Total: 240 + await harness.simulateTurn([ + { role: 'user', parts: [{ text: createMessage(2) }] }, + { role: 'model', parts: [{ text: createMessage(3) }] }, + ]); + + // Turn 2 (~120 tokens) Total: 360 (At retainedTokens boundary) + await harness.simulateTurn([ + { role: 'user', parts: [{ text: createMessage(4) }] }, + { role: 'model', parts: [{ text: createMessage(5) }] }, + ]); + + // Turn 3 (~120 tokens) Total: 480 (Exceeds retainedTokens! Triggers GC on Turn 0 & 1) + await harness.simulateTurn([ + { role: 'user', parts: [{ text: createMessage(6) }] }, + { role: 'model', parts: [{ text: createMessage(7) }] }, + ]); + + // Give the async background snapshot pipeline time to complete + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Turn 4 (~120 tokens). + // If GC succeeded, Turn 0 and 1 are now a ~10 token snapshot. + // Total should be: 10 (Snapshot) + 120 (Turn 2) + 120 (Turn 3) + 120 (Turn 4) = ~370 tokens. + await harness.simulateTurn([ + { role: 'user', parts: [{ text: createMessage(8) }] }, + { role: 'model', parts: [{ text: createMessage(9) }] }, + ]); + + const goldenState = await harness.getGoldenState(); + + // We should see a MOCKED_STATE_SNAPSHOT_SUMMARY rolling up Turns 0 and 1, + // while Turns 2, 3, and 4 remain fully intact. expect(goldenState).toMatchSnapshot(); }); }); diff --git a/packages/core/src/context/testing/contextTestUtils.ts b/packages/core/src/context/testing/contextTestUtils.ts index 898c098880..9cbdeec917 100644 --- a/packages/core/src/context/testing/contextTestUtils.ts +++ b/packages/core/src/context/testing/contextTestUtils.ts @@ -19,7 +19,10 @@ import { } from '../graph/types.js'; import type { ContextEnvironment } from '../pipeline/environment.js'; import type { Config } from '../../config/config.js'; -import type { BaseLlmClient } from '../../core/baseLlmClient.js'; +import type { + BaseLlmClient, + GenerateContentOptions, +} from '../../core/baseLlmClient.js'; import type { Content, GenerateContentResponse } from '@google/genai'; import { InboxSnapshotImpl } from '../pipeline/inbox.js'; import type { InboxMessage, ProcessArgs } from '../pipeline.js'; @@ -98,38 +101,38 @@ export function createDummyToolNode( export interface MockLlmClient extends BaseLlmClient { generateContent: Mock; + countTokens: Mock; } export function createMockLlmClient( responses?: Array, ): MockLlmClient { - const generateContentMock = vi.fn(); - - if (responses && responses.length > 0) { - for (const response of responses) { - if (typeof response === 'string') { - generateContentMock.mockResolvedValueOnce( - createMockGenerateContentResponse(response), + const generateContentMock = vi + .fn() + .mockImplementation((options: GenerateContentOptions) => { + // Array-based logic for backwards compatibility, if provided + if (responses && responses.length > 0) { + const callCount = generateContentMock.mock.calls.length - 1; + const idx = + callCount < responses.length ? callCount : responses.length - 1; + const res = responses[idx]; + return Promise.resolve( + typeof res === 'string' + ? createMockGenerateContentResponse(res) + : res, ); - } else { - generateContentMock.mockResolvedValueOnce(response); } - } - // Fallback to the last response for any subsequent calls - const lastResponse = responses[responses.length - 1]; - if (typeof lastResponse === 'string') { - generateContentMock.mockResolvedValue( - createMockGenerateContentResponse(lastResponse), + + const lastContent = options.contents[options.contents.length - 1]; + const lastPart = lastContent?.parts?.[lastContent.parts.length - 1]; + const lastPartString = JSON.stringify(lastPart ?? {}); + const contentSample = `${lastPartString.slice(0, 10)}...${lastPartString.slice(-10)}`; + return Promise.resolve( + createMockGenerateContentResponse( + `Mock response from: ${options.role}, for: ${contentSample}`, + ), ); - } else { - generateContentMock.mockResolvedValue(lastResponse); - } - } else { - // Default fallback - generateContentMock.mockResolvedValue( - createMockGenerateContentResponse('Mock LLM response'), - ); - } + }); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return { diff --git a/packages/core/src/context/utils/tokenCalibration.ts b/packages/core/src/context/utils/tokenCalibration.ts new file mode 100644 index 0000000000..f153e84db6 --- /dev/null +++ b/packages/core/src/context/utils/tokenCalibration.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import type { Content } from '@google/genai'; +import type { ContextEnvironment } from '../pipeline/environment.js'; +import type { ConcreteNode } from '../graph/types.js'; +import { debugLogger } from '../../utils/debugLogger.js'; + +export function performCalibration( + env: ContextEnvironment, + finalNodes: readonly ConcreteNode[], + finalContents: Content[], +) { + if (!env.renderOptions?.calibrateTokenCalculation) { + return; + } + + void (async () => { + try { + const exactResp = await env.llmClient.countTokens({ + contents: finalContents, + }); + const exactTokens = + typeof exactResp.totalTokens === 'number' ? exactResp.totalTokens : 0; + const estimatedTokens = + env.tokenCalculator.calculateConcreteListTokens(finalNodes); + + const delta = Math.abs(exactTokens - estimatedTokens); + const tolerance = Math.max(exactTokens, estimatedTokens) * 0.2; // 20% tolerance + + env.tracer.logEvent('Render', 'Token Calibration Measurement', { + exactTokens, + estimatedTokens, + delta, + isWithinTolerance: delta <= tolerance, + }); + + if (delta > tolerance) { + debugLogger.error( + `[Token Calibration] Large deviation detected: exact ${exactTokens} vs estimated ${estimatedTokens} (delta: ${delta})`, + ); + } + } catch { + // Ignore API failures during background calibration + } + })(); +} diff --git a/packages/core/src/core/baseLlmClient.ts b/packages/core/src/core/baseLlmClient.ts index 6352814f61..5c1b7f66fa 100644 --- a/packages/core/src/core/baseLlmClient.ts +++ b/packages/core/src/core/baseLlmClient.ts @@ -111,6 +111,11 @@ interface _CommonGenerateOptions { }; } +export interface CountTokenOptions { + modelConfigKey?: ModelConfigKey; + contents: Content[]; +} + /** * A client dedicated to stateless, utility-focused LLM calls. */ @@ -225,6 +230,20 @@ export class BaseLlmClient { return text; } + async countTokens( + options: CountTokenOptions, + ): Promise<{ totalTokens: number }> { + const model = options.modelConfigKey + ? this.config.modelConfigService.getResolvedConfig(options.modelConfigKey) + .model + : this.config.getActiveModel(); + const result = await this.contentGenerator.countTokens({ + model, + contents: options.contents, + }); + return { totalTokens: result.totalTokens || 0 }; + } + async generateContent( options: GenerateContentOptions, ): Promise {