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 {