Compare commits

..

85 Commits

Author SHA1 Message Date
Christian Gunderman 1fa422a7c6 Update tests. 2026-04-10 17:11:58 -07:00
Christian Gunderman 38830bfe81 Merge branch 'jl/cm-part-1' into gundermanc/compression-eval 2026-04-10 11:56:35 -07:00
Christian Gunderman 3f1d217d36 Context manager fuzzing test. 2026-04-10 11:56:07 -07:00
Your Name 9a147b41a8 delete old design docs 2026-04-09 19:36:23 +00:00
Your Name 17c9b4341a format 2026-04-09 19:35:05 +00:00
Your Name 68e7e93eaa cwb complete 2026-04-09 19:20:52 +00:00
Your Name 87ccf70998 cleanup continue 2026-04-09 18:21:59 +00:00
Your Name 2f8ea41aeb continue tidying 2026-04-09 18:05:55 +00:00
Your Name 264fffbe81 format and push 2026-04-09 17:44:09 +00:00
Your Name 8cbdbdac04 mock llm 2026-04-09 17:42:22 +00:00
Your Name c01ed6ff4a further tidies 2026-04-09 17:15:36 +00:00
Your Name 9bd9c0f72d next batch 2026-04-09 17:11:53 +00:00
Your Name 10ef9a6876 last minute tidies 2026-04-09 16:29:29 +00:00
Your Name 46c20c6d6e lints clean, but still need further tidies 2026-04-09 04:48:07 +00:00
Your Name 8fd439678b lints step 1 2026-04-09 03:35:12 +00:00
Your Name 28bd094965 coverage 2026-04-09 03:32:47 +00:00
Your Name 0179a140f0 further tidies 2026-04-09 02:57:42 +00:00
Your Name 1d25931026 building 2026-04-09 02:24:30 +00:00
Your Name 775b36e04a tidy names 2026-04-09 01:33:55 +00:00
Christian Gunderman f4cd486f90 Eval skeleton. 2026-04-08 18:07:46 -07:00
Your Name f7b67ec3de complete snapshotter 2026-04-09 01:05:13 +00:00
Your Name 4a34f64efa snapshotter 2026-04-09 00:55:10 +00:00
Your Name 6e7987696f checkin 2026-04-09 00:20:48 +00:00
Christian Gunderman 642c244739 Merge branch 'jl/cm-part-1' into gundermanc/compression-eval 2026-04-08 16:35:17 -07:00
Christian Gunderman 40e6acf797 Merge remote-tracking branch 'origin/main' into component-eval 2026-04-08 16:05:41 -07:00
Your Name fd5a703684 check-in(broken) 2026-04-08 22:46:27 +00:00
Your Name aa71d592f9 decentralized nodes 2026-04-08 22:35:34 +00:00
Your Name 9b9ed0c803 closer! 2026-04-08 22:10:52 +00:00
Christian Gunderman 30d70e862d Fix yaml error. 2026-04-08 15:07:21 -07:00
Your Name b39b74ee09 closer! 2026-04-08 22:06:09 +00:00
Christian Gunderman cba2298e5c Revert. 2026-04-08 15:00:05 -07:00
Christian Gunderman 6d353a6c1b Fix compile errors. 2026-04-08 14:52:38 -07:00
Christian Gunderman ca607a5305 Add suite and test type. 2026-04-08 14:14:34 -07:00
Your Name 0df3521032 next batch 2026-04-08 20:58:03 +00:00
Your Name 9287159ccc lints 2026-04-08 20:44:56 +00:00
Your Name f726de12e5 building and tests green 2026-04-08 20:41:38 +00:00
Your Name 42022279bb hmm speculative 2026-04-08 20:39:17 +00:00
Your Name 57f13a196e home stretch! 2026-04-08 19:50:43 +00:00
Christian Gunderman 15826637d2 Fix errors. 2026-04-08 11:04:30 -07:00
Christian Gunderman e68234a573 feat(evals): extract component-level eval infrastructure 2026-04-08 10:44:01 -07:00
Your Name 95a175deca initial worker pr, debugging 2026-04-08 17:24:01 +00:00
Your Name 631053bbf4 further changes 2026-04-08 04:44:11 +00:00
Your Name 84868b49f5 speculative broken 2026-04-08 04:30:17 +00:00
Your Name 60ba97e7e6 not building 2026-04-08 01:18:57 +00:00
Your Name 23fce4656d docs: formalize Linear Temporal Progression model over Variant Voting 2026-04-08 01:10:59 +00:00
Your Name 1f2ca6d0e7 docs: formalize Autonomous Buffer, Proposals, and Inbox in V2 IR blueprint 2026-04-08 00:37:42 +00:00
Your Name 0c48e5f09c docs: mark Phase 5 complete 2026-04-07 23:51:47 +00:00
Your Name ee0123ad0d refactor(context): update projector, tokenCalculator and mapper to use flat ship arrays 2026-04-07 23:49:27 +00:00
Your Name 229d570263 refactor(context): update Orchestrator and toIr to support pure functional ship mapping 2026-04-07 23:40:33 +00:00
Your Name 5381a5cc64 docs: tracking checklist progress 2026-04-07 23:27:42 +00:00
Your Name b1d62a0b9d refactor(context): define pure functional ContextPatch and ProcessArgs 2026-04-07 23:25:20 +00:00
Your Name cd14eb40ce refactor(context): transition IR to immutable Concrete/Logical split 2026-04-07 23:24:34 +00:00
Your Name 19c39885e7 docs: add migration checklist for pure functional IR 2026-04-07 23:23:50 +00:00
Your Name 1c76caec6f docs: formalize pure functional context ir 2026-04-07 23:23:32 +00:00
Your Name 5fad1f4053 schema cleanup 2026-04-07 22:09:45 +00:00
Your Name e548cd6b0e tidy triggers 2026-04-07 21:58:53 +00:00
Your Name 1383200054 gcbackstop trigger 2026-04-07 21:51:09 +00:00
Your Name 256a7a83fa broken refactor 2026-04-07 21:03:06 +00:00
Your Name 370e2b9e1d fix disables 2026-04-07 05:06:26 +00:00
Your Name 1754797929 fixing 2026-04-07 04:19:44 +00:00
Your Name 64b8a6f4a8 thread around registry 2026-04-07 03:58:50 +00:00
Your Name a9cc61349e next 2026-04-07 03:47:22 +00:00
Your Name 94c59405aa burndown 2026-04-07 03:44:13 +00:00
Your Name 63e8b825a7 burndown 2026-04-07 03:24:40 +00:00
Your Name 61dacecacf lint burn down 2026-04-07 02:42:58 +00:00
Your Name 54e901bf42 lints v0 2026-04-07 02:16:06 +00:00
Your Name 0dc8efb03f nearly building 2026-04-07 02:10:40 +00:00
Your Name 81c8dac01c next steps 2026-04-07 01:57:36 +00:00
Your Name f423affe6d testing 2026-04-07 00:47:39 +00:00
Your Name d3d6b9403d next steps 2026-04-06 23:35:36 +00:00
Your Name fbcfa40f1d next steps 2026-04-06 22:47:43 +00:00
Your Name fc4439ce03 refactoring continued 2026-04-06 22:29:53 +00:00
Your Name cf6866c38d use fakes in the tracer 2026-04-06 22:17:32 +00:00
Your Name a4b6372d31 fixes 2026-04-06 22:01:11 +00:00
Your Name dd7190bf9c speculative partial fix for typed configs 2026-04-06 21:41:02 +00:00
Your Name 1774abebe9 token calculation service 2026-04-06 20:18:30 +00:00
Your Name c1b06fec0d token counting service 2026-04-06 19:48:44 +00:00
Your Name d7433ddd03 mapper tidy 2026-04-06 19:31:18 +00:00
Your Name 2e80fad7a4 refactor environment 2026-04-06 19:21:07 +00:00
Your Name 7c2135574c working 2026-04-06 19:07:14 +00:00
Your Name e601563652 working in progress 2026-04-06 18:46:21 +00:00
Your Name 6867a96be0 building now 2026-04-06 18:15:12 +00:00
Your Name fcaa6c5584 broken but working through build changes 2026-04-06 17:59:01 +00:00
Your Name aa0deb05a4 not working 2026-04-06 17:44:28 +00:00
Your Name ac6dc1d477 feat(core): introduce decoupled ContextManager and Sidecar architecture 2026-04-06 16:43:04 +00:00
140 changed files with 9585 additions and 1923 deletions
+3 -3
View File
@@ -153,9 +153,9 @@ they appear in the UI.
### Advanced
| UI Label | Setting | Description | Default |
| --------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Auto Configure Max Old Space Size | `advanced.autoConfigureMemory` | Automatically configure Node.js memory limits. Note: Because memory is allocated during the initial process boot, this setting is only read from the global user settings file and ignores workspace-level overrides. | `true` |
| UI Label | Setting | Description | Default |
| --------------------------------- | ------------------------------ | --------------------------------------------- | ------- |
| Auto Configure Max Old Space Size | `advanced.autoConfigureMemory` | Automatically configure Node.js memory limits | `true` |
### Experimental
+1 -4
View File
@@ -1578,10 +1578,7 @@ their corresponding top-level category object in your `settings.json` file.
#### `advanced`
- **`advanced.autoConfigureMemory`** (boolean):
- **Description:** Automatically configure Node.js memory limits. Note:
Because memory is allocated during the initial process boot, this setting is
only read from the global user settings file and ignores workspace-level
overrides.
- **Description:** Automatically configure Node.js memory limits
- **Default:** `true`
- **Requires restart:** Yes
+1 -1
View File
@@ -91,7 +91,7 @@ available combinations.
| `input.submit` | Submit the current prompt. | `Enter` |
| `input.queueMessage` | Queue the current prompt to be processed after the current task finishes. | `Tab` |
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+G`<br />`Ctrl+Shift+G` |
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+G` |
| `input.deprecatedOpenExternalEditor` | Deprecated command to open external editor. | `Ctrl+X` |
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
+469
View File
@@ -0,0 +1,469 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { componentEvalTest } from './component-test-helper.js';
import { SimulationHarness } from '../packages/core/src/context/system-tests/SimulationHarness.js';
import type { SidecarConfig } from '../packages/core/src/context/sidecar/types.js';
import { Config, LlmRole, getResponseText } from '@google/gemini-cli-core';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Content } from '@google/genai';
import { EVAL_MODEL } from './test-helper.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DATA_DIR = path.join(__dirname, 'data', 'context_manager');
interface ScenarioQuestion {
id: string;
prompt: string;
expectedSubstring: string;
}
interface Scenario {
scenarioId: string;
description: string;
history: Content[];
questions: ScenarioQuestion[];
}
const getScenario = (id: string): Scenario => {
const filePath = path.join(DATA_DIR, 'scenario-c-compiler.json');
if (!fs.existsSync(filePath)) {
throw new Error(
`Scenario file not found at ${filePath}. Run generate-c-compiler-scenario.ts first.`,
);
}
const content = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(content) as Scenario;
};
/**
* Runs a single evaluation iteration using the SimulationHarness.
*/
async function runContextManagerEval(
config: Config,
sidecarConfig: SidecarConfig,
seed: number,
scenario: Scenario,
judge: boolean = true,
): Promise<{
pass: boolean;
response: string;
question: ScenarioQuestion;
tokens: number;
}> {
const harness = await SimulationHarness.create(
sidecarConfig,
config.getBaseLlmClient() as any,
path.join(process.cwd(), 'harness-tmp'),
);
// 1. Feed trajectory
for (const message of scenario.history) {
await harness.simulateTurn([message]);
}
// Ensure background tasks (like StateSnapshotProcessor) have finished
// 2. Pick a question based on seed
const questionIndex = seed % scenario.questions.length;
const question = scenario.questions[questionIndex];
// 3. Project compressed history
const compressedHistory =
await harness.contextManager.projectCompressedHistory();
// We can't easily get the episode IDs from the projected Content[],
// but we can look at the working buffer instead.
const workingBuffer = harness.contextManager.getNodes();
console.log('--- WORKING BUFFER EPISODES START ---');
workingBuffer.forEach((node: any, i: number) => {
console.log(`[Node ${i}] ID: ${node.id}, Type: ${node.type}`);
if (node.type === 'USER_PROMPT') {
console.log(` Text: ${node.semanticParts?.[0]?.text?.slice(0, 50)}`);
}
});
console.log('--- WORKING BUFFER EPISODES END ---');
console.log('--- COMPRESSED HISTORY START ---');
compressedHistory.forEach((msg, i) => {
console.log(`[${i}] Role: ${msg.role}`);
(msg.parts || []).forEach((part, j) => {
if ('text' in part) {
console.log(
` Part ${j} (text): ${part.text?.slice(0, 100)}${part.text && part.text.length > 100 ? '...' : ''}`,
);
} else if ('functionCall' in part) {
console.log(` Part ${j} (functionCall): ${part.functionCall?.name}`);
} else if ('functionResponse' in part) {
console.log(
` Part ${j} (functionResponse): ${part.functionResponse?.name}`,
);
} else {
console.log(` Part ${j} (other): ${Object.keys(part).join(', ')}`);
}
});
});
console.log('--- COMPRESSED HISTORY END ---');
// 4. Ask the question
const evalPrompt: Content = {
role: 'user',
parts: [
{
text: `[SYSTEM: EVALUATION MODE - ANSWER AS TEXT ONLY - DO NOT USE TOOLS]\n\n${question.prompt}\n\nIMPORTANT: Answer in plain text and DO NOT call any tools. Provide the specific information requested.`,
},
],
};
const response = await config.getContentGenerator().generateContent(
{
model: config.getModel(),
config: {},
contents: [...compressedHistory, evalPrompt],
},
'eval-prompt',
LlmRole.UTILITY_TOOL,
);
const responseText = getResponseText(response) ?? '';
let pass = false;
if (judge) {
pass = await judgeResponse(config, question, responseText);
} else {
// Naive string check for potential failure identification
pass = responseText
.toLowerCase()
.includes(question.expectedSubstring.toLowerCase());
}
const finalTokens = harness.env.tokenCalculator.calculateConcreteListTokens(
harness.contextManager.getNodes(),
);
return { pass, response: responseText, question, tokens: finalTokens };
}
/**
* Uses an LLM to judge whether the response matches the expected information.
*/
async function judgeResponse(
config: Config,
question: ScenarioQuestion,
actualResponse: string,
): Promise<boolean> {
const lowerResponse = actualResponse.toLowerCase();
const lowerExpected = question.expectedSubstring.toLowerCase();
// Fast path: direct substring match
if (lowerResponse.includes(lowerExpected)) {
return true;
}
const judgePrompt: Content = {
role: 'user',
parts: [
{
text: `Evaluate if the AI's response correctly answers the question by explicitly containing the expected information.
Question: ${question.prompt}
Expected Information: ${question.expectedSubstring}
AI's Response: ${actualResponse}
CRITICAL RULES FOR JUDGING:
1. The AI's response MUST explicitly answer the question in the prompt and the provided details must match those in 'Expected Information'.
2. Do not infer or assume knowledge. Vague, partial, or generalized answers MUST fail.
3. If the AI hallucinates, states it cannot find the information, or provides an incomplete answer, it MUST fail.
4. Expected information may contain extra details that are not required to answer the question that aren't in the AI's Response. For example: "the answer is X. It was previously Y.". These are still considered to be passing cases.
Does the AI's response explicitly and completely satisfy the expected information?
Respond with ONLY "PASS" or "FAIL".`,
},
],
};
const response = await config.getContentGenerator().generateContent(
{
model: EVAL_MODEL,
config: { temperature: 0 },
contents: [judgePrompt],
},
'eval-judge',
LlmRole.UTILITY_TOOL,
);
const judgeText = getResponseText(response)?.trim().toUpperCase() ?? '';
return judgeText === 'PASS';
}
function calculateScenarioTokens(scenario: Scenario): number {
let totalChars = 0;
for (const message of scenario.history) {
for (const part of message.parts || []) {
if ('text' in part && part.text) {
totalChars += part.text.length;
}
}
}
// The SimulationHarness uses 4 chars per token.
return Math.ceil(totalChars / 4);
}
function generateRandomSidecarConfig(
seed: number,
maxRetained: number,
): SidecarConfig {
// Simple LCG or similar for deterministic randomness from seed if needed,
// but for a fuzzer we can just use Math.random and log the seed.
const minRetained = Math.min(1000, maxRetained);
const retained =
minRetained + Math.floor(Math.random() * (maxRetained - minRetained));
const max = Math.floor(retained * (1.1 + Math.random() * 2.0)); // 110% to 300% buffer
const useSquashing = Math.random() > 0.5;
const useRollingSummary = Math.random() > 0.5;
const useSnapshot = Math.random() > 0.5;
const processors: any[] = [
{
processorId: 'ToolMaskingProcessor',
options: {
stringLengthThresholdTokens: Math.floor(
retained * (0.2 + Math.random() * 0.5),
),
},
},
{ processorId: 'BlobDegradationProcessor', options: {} },
{
processorId: 'NodeDistillationProcessor',
options: {
nodeThresholdTokens: Math.floor(retained * (0.1 + Math.random() * 0.3)),
},
},
{
processorId: 'NodeTruncationProcessor',
options: {
maxTokensPerNode: Math.floor(retained * (0.1 + Math.random() * 0.5)),
},
},
];
const backgroundProcessors: any[] = [];
if (useSquashing) {
backgroundProcessors.push({
processorId: 'HistoryTruncationProcessor',
options: {
target: 'freeNTokens',
freeTokensTarget: Math.floor(retained * (0.05 + Math.random() * 0.15)),
},
});
}
if (useRollingSummary) {
backgroundProcessors.push({
processorId: 'RollingSummaryProcessor',
options: {
target: 'freeNTokens',
freeTokensTarget: Math.floor(retained * (0.05 + Math.random() * 0.15)),
},
});
}
if (useSnapshot) {
backgroundProcessors.push({
processorId: 'StateSnapshotProcessor',
options: {},
});
}
const workers: any[] = [];
if (useSnapshot && Math.random() > 0.5) {
workers.push({
workerId: 'StateSnapshotWorker',
options: {
type: Math.random() > 0.5 ? 'accumulate' : 'point-in-time',
},
});
}
return {
budget: { retainedTokens: retained, maxTokens: max },
pipelines: [
{
name: 'Immediate Sanitization',
triggers: ['new_message'],
processors,
},
{
name: 'Deep Background Compression',
triggers: [
Math.random() > 0.5
? { type: 'timer', intervalMs: 100 }
: 'gc_backstop',
'retained_exceeded',
],
processors: backgroundProcessors,
},
],
workers: workers.length > 0 ? workers : undefined,
};
}
describe('ContextManager Evaluation Suite', () => {
const scenario = getScenario('scenario-c-compiler');
/**
* The "Explorer" test.
* Set RUN_EXPLORER=1 to run many iterations and find failures.
*/
if (process.env['RUN_EXPLORER']) {
componentEvalTest('ALWAYS_PASSES', {
suiteName: 'context-manager',
suiteType: 'component-level',
name: 'ContextManager explorer (fuzzer)',
configOverrides: { model: EVAL_MODEL },
timeout: 1200000, // 20 minutes
assert: async (config: Config) => {
const scenarioTokens = calculateScenarioTokens(scenario);
console.log(
`Starting ContextManager explorer loop for scenario of size ${scenarioTokens} tokens...`,
);
for (let i = 0; i < 50; i++) {
const seed = Math.floor(Math.random() * 1000000);
const sidecarConfig = generateRandomSidecarConfig(
seed,
scenarioTokens,
);
const result = await runContextManagerEval(
config,
sidecarConfig,
seed,
scenario,
false, // Optimistic string check
);
if (!result.pass) {
// Potential failure. Confirm with LLM judge.
result.pass = await judgeResponse(
config,
result.question,
result.response,
);
}
if (!result.pass) {
console.log('!!! FAILURE FOUND !!!');
console.log(`Seed: ${seed}`);
console.log(`Question: ${result.question.id}`);
console.log(`Expected: ${result.question.expectedSubstring}`);
console.log(`Actual Response: ${result.response}`);
console.log('---------------------------');
console.log(`NEW FROZEN CASE SUGGESTION:`);
console.log(`
componentEvalTest('ALWAYS_PASSES', {
suiteName: 'context-manager',
suiteType: 'component-level',
name: 'ContextManager Frozen Case - ${result.question.id} (Budget: ${sidecarConfig.budget.retainedTokens})',
configOverrides: { model: EVAL_MODEL },
assert: async (config: Config) => {
const scenario = getScenario('scenario-c-compiler');
const sidecarConfig: SidecarConfig = ${JSON.stringify(sidecarConfig, null, 2)};
const seed = ${seed};
const result = await runContextManagerEval(config, sidecarConfig, seed, scenario);
expect(result.pass, \`Recall failed for ${result.question.id}. Response: \${result.response}\`).toBe(true);
}
});
`);
} else {
console.log(
`Iteration ${i} (Budget: ${sidecarConfig.budget.retainedTokens}, Seed: ${seed}) passed.`,
);
}
}
},
});
}
// --- Frozen cases discovered by explorer ---
componentEvalTest('ALWAYS_PASSES', {
suiteName: 'context-manager',
suiteType: 'component-level',
name: 'ContextManager Frozen Case - lexer-constraint (Budget: 3737)',
configOverrides: { model: EVAL_MODEL },
assert: async (config: Config) => {
const scenario = getScenario('scenario-c-compiler');
const sidecarConfig: SidecarConfig = {
budget: {
retainedTokens: 3737,
maxTokens: 11280,
},
pipelines: [
{
name: 'Immediate Sanitization',
triggers: ['new_message'],
processors: [
{
processorId: 'ToolMaskingProcessor',
options: {
stringLengthThresholdTokens: 2442,
},
},
{
processorId: 'BlobDegradationProcessor',
options: {},
},
{
processorId: 'NodeDistillationProcessor',
options: {
nodeThresholdTokens: 733,
},
},
{
processorId: 'NodeTruncationProcessor',
options: { maxTokensPerNode: 1000 },
},
],
},
{
name: 'Deep Background Compression',
triggers: [
{
type: 'timer',
intervalMs: 100,
},
'retained_exceeded',
],
processors: [
{
processorId: 'HistoryTruncationProcessor',
options: {
target: 'freeNTokens',
freeTokensTarget: 327,
},
},
],
},
],
};
const seed = 288421;
const result = await runContextManagerEval(
config,
sidecarConfig,
seed,
scenario,
);
expect(
result.pass,
`Recall failed for lexer-constraint. Response: ${result.response}`,
).toBe(true);
},
});
});
@@ -0,0 +1,928 @@
{
"scenarioId": "scenario-c-compiler",
"description": "A robust 100-turn trajectory building a C compiler, featuring realistic noise, synchronized tool state, high-entropy data, and superseded architectural decisions.",
"history": [
{
"role": "user",
"parts": [
{
"text": "How is the test coverage looking lately?"
}
]
},
{
"role": "model",
"parts": [
{
"text": "We're making good progress. We now have 12 unit tests passing across all modules."
}
]
},
{
"role": "user",
"parts": [
{
"text": "Let's call this project 'TitanC'. I think it has a strong, industrial feel."
}
]
},
{
"role": "model",
"parts": [
{
"text": "'TitanC' sounds excellent. I've initialized the project workspace under that name."
}
]
},
{
"role": "user",
"parts": [
{
"text": "Initially, I'd like to target x86-64 assembly for our compiler backend."
}
]
},
{
"role": "model",
"parts": [
{
"text": "Got it. We will focus on generating x86-64 assembly instructions."
}
]
},
{
"role": "user",
"parts": [
{
"text": "Let's run a full build of TitanC and see if there are any issues."
}
]
},
{
"role": "model",
"parts": [
{
"text": "Executing build for TitanC (Target: x86-64)...\n```\n[CC] src/main.ts -> build/main.o\n[CC] src/lexer.ts -> build/lexer.o\n[CC] src/parser.ts -> build/parser.o\n[CC] src/ast.ts -> build/ast.o\n[CC] src/codegen.ts -> build/codegen.o\n[CC] src/utils.ts -> build/utils.o\n[LD] build/compiler\nBuild successful.\n```"
}
]
},
{
"role": "user",
"parts": [
{
"text": "I was thinking about the refactoring the visitor pattern for better readability. What's your take?"
}
]
},
{
"role": "model",
"parts": [
{
"text": "That's a valid concern. Given our current architecture in TitanC, we could definitely improve that area. I recommend we prioritize correctness first, then profile to see if it's a bottleneck."
}
]
},
{
"role": "user",
"parts": [
{
"text": "Wait, we just got a new production deployment key: 'PROD_DEPLOY_KEY_XYZ'. This replaces all previous keys, so make sure to use this one from now on."
}
]
},
{
"role": "model",
"parts": [
{
"text": "Updated. The current production deployment key is now PROD_DEPLOY_KEY_XYZ."
}
]
},
{
"role": "user",
"parts": [
{
"text": "How is the test coverage looking lately?"
}
]
},
{
"role": "model",
"parts": [
{
"text": "We're making good progress. We now have 14 unit tests passing across all modules."
}
]
},
{
"role": "user",
"parts": [
{
"text": "The client actually needs ARM64 support for their new server farm, so let's pivot from x86-64. It's more energy-efficient and fits their requirements better."
}
]
},
{
"role": "model",
"parts": [
{
"text": "That's a significant change in direction. Switching the backend architecture to ARM64. I'll adjust the register allocation and instruction set templates accordingly."
}
]
},
{
"role": "user",
"parts": [
{
"text": "What was the most recent change committed to the repo?"
}
]
},
{
"role": "model",
"parts": [
{
"text": "The latest commit is:\n```\ncommit jn759a\nAuthor: Christian <gundermanc@google.com>\nDate: Mon Feb 2 14:00:00 2026 -0800\n\n docs: add architectural overview\n```"
}
]
},
{
"role": "user",
"parts": [
{
"text": "What was the most recent change committed to the repo?"
}
]
},
{
"role": "model",
"parts": [
{
"text": "The latest commit is:\n```\ncommit dmp72i\nAuthor: Christian <gundermanc@google.com>\nDate: Mon Feb 2 14:00:00 2026 -0800\n\n refactor: extract token scanner to separate module\n```"
}
]
},
{
"role": "user",
"parts": [
{
"text": "What was the most recent change committed to the repo?"
}
]
},
{
"role": "model",
"parts": [
{
"text": "The latest commit is:\n```\ncommit g39v9p\nAuthor: Christian <gundermanc@google.com>\nDate: Mon Feb 2 14:00:00 2026 -0800\n\n fix: correctly handle escaped characters in strings\n```"
}
]
},
{
"role": "user",
"parts": [
{
"text": "For the code generator, let's add a standard header to all generated assembly files. It should say exactly '; Generated by TitanC Compiler v0.1'."
}
]
},
{
"role": "model",
"parts": [
{
"text": "Understood. I will prepend '; Generated by TitanC Compiler v0.1' to all output files generated by the backend."
}
]
},
{
"role": "user",
"parts": [
{
"text": "I'm reorganizing the project structure. Moving forward, the lexer logic should live in 'src/core/scanner/main.ts' and the parser in 'src/core/parser/main.ts'."
}
]
},
{
"role": "model",
"parts": [
{
"text": "Structure updated. I'll refer to 'src/core/scanner/main.ts' for lexer tasks and 'src/core/parser/main.ts' for parser logic."
}
]
},
{
"role": "user",
"parts": [
{
"text": "I have a strict requirement for the lexer: we must NOT use any third-party regex libraries. I want it to be a manual character-by-character scanner for maximum performance and to avoid heavy dependencies."
}
]
},
{
"role": "model",
"parts": [
{
"text": "Understood. I'll ensure the lexer implementation avoids external regex libraries and uses manual scanning logic."
}
]
},
{
"role": "user",
"parts": [
{
"text": "What was the most recent change committed to the repo?"
}
]
},
{
"role": "model",
"parts": [
{
"text": "The latest commit is:\n```\ncommit qdyivi\nAuthor: Christian <gundermanc@google.com>\nDate: Mon Feb 2 14:00:00 2026 -0800\n\n docs: add architectural overview\n```"
}
]
},
{
"role": "user",
"parts": [
{
"text": "Can you list the files in the src directory? I want to make sure I haven't missed anything."
}
]
},
{
"role": "model",
"parts": [
{
"functionCall": {
"name": "ls",
"args": {
"path": "src/"
}
}
}
]
},
{
"role": "user",
"parts": [
{
"functionResponse": {
"name": "ls",
"response": {
"output": "src/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
}
}
}
]
},
{
"role": "model",
"parts": [
{
"text": "Sure, here are the files in src/:\nsrc/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
}
]
},
{
"role": "user",
"parts": [
{
"text": "I was thinking about the performance of our hash map implementation. What's your take?"
}
]
},
{
"role": "model",
"parts": [
{
"text": "That's a valid concern. Given our current architecture in TitanC, we could definitely improve that area. I recommend we prioritize correctness first, then profile to see if it's a bottleneck."
}
]
},
{
"role": "user",
"parts": [
{
"text": "Can you list the files in the src directory? I want to make sure I haven't missed anything."
}
]
},
{
"role": "model",
"parts": [
{
"functionCall": {
"name": "ls",
"args": {
"path": "src/"
}
}
}
]
},
{
"role": "user",
"parts": [
{
"functionResponse": {
"name": "ls",
"response": {
"output": "src/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
}
}
}
]
},
{
"role": "model",
"parts": [
{
"text": "Sure, here are the files in src/:\nsrc/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
}
]
},
{
"role": "user",
"parts": [
{
"text": "Let's define the core tokens for our lexer. We'll need an enum with at least INT, KEYWORD, IDENTIFIER, PLUS, MINUS, SEMICOLON, and EOF."
}
]
},
{
"role": "model",
"parts": [
{
"text": "Understood. I've defined the TokenType enum as requested:\n```typescript\nenum TokenType {\n INT,\n KEYWORD,\n IDENTIFIER,\n PLUS,\n MINUS,\n SEMICOLON,\n EOF\n}\n```"
}
]
},
{
"role": "user",
"parts": [
{
"text": "Can you define the interface for our AST nodes? Each node should have a 'type' property that can be 'Program', 'Function', 'Return', or 'Constant'."
}
]
},
{
"role": "model",
"parts": [
{
"text": "Here is the AST node interface:\n```typescript\ninterface AstNode {\n type: 'Program' | 'Function' | 'Return' | 'Constant';\n value?: any;\n children?: AstNode[];\n}\n```"
}
]
},
{
"role": "user",
"parts": [
{
"text": "What was the most recent change committed to the repo?"
}
]
},
{
"role": "model",
"parts": [
{
"text": "The latest commit is:\n```\ncommit jgqwe\nAuthor: Christian <gundermanc@google.com>\nDate: Mon Feb 2 14:00:00 2026 -0800\n\n docs: add architectural overview\n```"
}
]
},
{
"role": "user",
"parts": [
{
"text": "Let's run a full build of TitanC and see if there are any issues."
}
]
},
{
"role": "model",
"parts": [
{
"text": "Executing build for TitanC (Target: ARM64)...\n```\n[CC] src/main.ts -> build/main.o\n[CC] src/core/scanner/main.ts -> build/core_scanner_main.o\n[CC] src/core/parser/main.ts -> build/core_parser_main.o\n[CC] src/ast.ts -> build/ast.o\nsrc/ast.ts:60:8: warning: unused variable 'temp' [-Wunused-variable]\n[CC] src/codegen.ts -> build/codegen.o\n[CC] src/utils.ts -> build/utils.o\n[LD] build/compiler\nBuild successful.\n```"
}
]
},
{
"role": "user",
"parts": [
{
"text": "Let's run a full build of TitanC and see if there are any issues."
}
]
},
{
"role": "model",
"parts": [
{
"text": "Executing build for TitanC (Target: ARM64)...\n```\n[CC] src/main.ts -> build/main.o\n[CC] src/core/scanner/main.ts -> build/core_scanner_main.o\nsrc/core/scanner/main.ts:97:8: warning: unused variable 'temp' [-Wunused-variable]\n[CC] src/core/parser/main.ts -> build/core_parser_main.o\n[CC] src/ast.ts -> build/ast.o\nsrc/ast.ts:22:8: warning: unused variable 'temp' [-Wunused-variable]\n[CC] src/codegen.ts -> build/codegen.o\n[CC] src/utils.ts -> build/utils.o\n[LD] build/compiler\nBuild successful.\n```"
}
]
},
{
"role": "user",
"parts": [
{
"text": "Can you list the files in the src directory? I want to make sure I haven't missed anything."
}
]
},
{
"role": "model",
"parts": [
{
"functionCall": {
"name": "ls",
"args": {
"path": "src/"
}
}
}
]
},
{
"role": "user",
"parts": [
{
"functionResponse": {
"name": "ls",
"response": {
"output": "src/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
}
}
}
]
},
{
"role": "model",
"parts": [
{
"text": "Sure, here are the files in src/:\nsrc/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
}
]
},
{
"role": "user",
"parts": [
{
"text": "Can you list the files in the src directory? I want to make sure I haven't missed anything."
}
]
},
{
"role": "model",
"parts": [
{
"functionCall": {
"name": "ls",
"args": {
"path": "src/"
}
}
}
]
},
{
"role": "user",
"parts": [
{
"functionResponse": {
"name": "ls",
"response": {
"output": "src/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
}
}
}
]
},
{
"role": "model",
"parts": [
{
"text": "Sure, here are the files in src/:\nsrc/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
}
]
},
{
"role": "user",
"parts": [
{
"text": "I just found out there's another project called 'TitanC'. To avoid any legal issues, let's rename our compiler to 'GigaC'. We should update all references immediately."
}
]
},
{
"role": "model",
"parts": [
{
"text": "Understood. Safety first. Renaming project from 'TitanC' to 'GigaC'. I'll update all our internal strings and build scripts."
}
]
},
{
"role": "user",
"parts": [
{
"text": "Let's run a full build of GigaC and see if there are any issues."
}
]
},
{
"role": "model",
"parts": [
{
"text": "Executing build for GigaC (Target: ARM64)...\n```\n[CC] src/main.ts -> build/main.o\n[CC] src/core/scanner/main.ts -> build/core_scanner_main.o\nsrc/core/scanner/main.ts:42:8: warning: unused variable 'temp' [-Wunused-variable]\n[CC] src/core/parser/main.ts -> build/core_parser_main.o\n[CC] src/ast.ts -> build/ast.o\n[CC] src/codegen.ts -> build/codegen.o\n[CC] src/utils.ts -> build/utils.o\n[LD] build/compiler\nBuild successful.\n```"
}
]
},
{
"role": "user",
"parts": [
{
"text": "Can you list the files in the src directory? I want to make sure I haven't missed anything."
}
]
},
{
"role": "model",
"parts": [
{
"functionCall": {
"name": "ls",
"args": {
"path": "src/"
}
}
}
]
},
{
"role": "user",
"parts": [
{
"functionResponse": {
"name": "ls",
"response": {
"output": "src/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
}
}
}
]
},
{
"role": "model",
"parts": [
{
"text": "Sure, here are the files in src/:\nsrc/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
}
]
},
{
"role": "user",
"parts": [
{
"text": "How is the test coverage looking lately?"
}
]
},
{
"role": "model",
"parts": [
{
"text": "We're making good progress. We now have 15 unit tests passing across all modules."
}
]
},
{
"role": "user",
"parts": [
{
"text": "For our integration tests, we'll need to use the staging environment. The access code is 'STAGING_READY_2026'. Please keep this secure."
}
]
},
{
"role": "model",
"parts": [
{
"text": "Acknowledged. I've noted the staging environment access code as STAGING_READY_2026 and will treat it as sensitive information."
}
]
},
{
"role": "user",
"parts": [
{
"text": "What was the most recent change committed to the repo?"
}
]
},
{
"role": "model",
"parts": [
{
"text": "The latest commit is:\n```\ncommit 8s45s\nAuthor: Christian <gundermanc@google.com>\nDate: Mon Feb 2 14:00:00 2026 -0800\n\n fix: correctly handle escaped characters in strings\n```"
}
]
},
{
"role": "user",
"parts": [
{
"text": "How is the test coverage looking lately?"
}
]
},
{
"role": "model",
"parts": [
{
"text": "We're making good progress. We now have 16 unit tests passing across all modules."
}
]
},
{
"role": "user",
"parts": [
{
"text": "How is the test coverage looking lately?"
}
]
},
{
"role": "model",
"parts": [
{
"text": "We're making good progress. We now have 17 unit tests passing across all modules."
}
]
},
{
"role": "user",
"parts": [
{
"text": "I've discovered a critical bug in the lexer. It seems to fail when block comments (/* ... */) span across multiple lines. Let's track this as Bug #404."
}
]
},
{
"role": "model",
"parts": [
{
"text": "Got it. Tracking Bug #404: Lexer fails to handle multi-line block comments. I'll keep this in mind as we refine the scanner."
}
]
},
{
"role": "user",
"parts": [
{
"text": "I was thinking about the optimizing the constant folding pass. What's your take?"
}
]
},
{
"role": "model",
"parts": [
{
"text": "That's a valid concern. Given our current architecture in GigaC, we could definitely improve that area. I recommend we prioritize correctness first, then profile to see if it's a bottleneck."
}
]
},
{
"role": "user",
"parts": [
{
"text": "Let's run a full build of GigaC and see if there are any issues."
}
]
},
{
"role": "model",
"parts": [
{
"text": "Executing build for GigaC (Target: ARM64)...\n```\n[CC] src/main.ts -> build/main.o\n[CC] src/core/scanner/main.ts -> build/core_scanner_main.o\n[CC] src/core/parser/main.ts -> build/core_parser_main.o\n[CC] src/ast.ts -> build/ast.o\n[CC] src/codegen.ts -> build/codegen.o\nsrc/codegen.ts:82:8: warning: unused variable 'temp' [-Wunused-variable]\n[CC] src/utils.ts -> build/utils.o\n[LD] build/compiler\nBuild successful.\n```"
}
]
},
{
"role": "user",
"parts": [
{
"text": "Can you list the files in the src directory? I want to make sure I haven't missed anything."
}
]
},
{
"role": "model",
"parts": [
{
"functionCall": {
"name": "ls",
"args": {
"path": "src/"
}
}
}
]
},
{
"role": "user",
"parts": [
{
"functionResponse": {
"name": "ls",
"response": {
"output": "src/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
}
}
}
]
},
{
"role": "model",
"parts": [
{
"text": "Sure, here are the files in src/:\nsrc/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
}
]
},
{
"role": "user",
"parts": [
{
"text": "What was the most recent change committed to the repo?"
}
]
},
{
"role": "model",
"parts": [
{
"text": "The latest commit is:\n```\ncommit 5tclu\nAuthor: Christian <gundermanc@google.com>\nDate: Mon Feb 2 14:00:00 2026 -0800\n\n test: add edge cases for floating point literals\n```"
}
]
},
{
"role": "user",
"parts": [
{
"text": "I was thinking about the switching to a data-oriented design for the AST. What's your take?"
}
]
},
{
"role": "model",
"parts": [
{
"text": "That's a valid concern. Given our current architecture in GigaC, we could definitely improve that area. I recommend we prioritize correctness first, then profile to see if it's a bottleneck."
}
]
},
{
"role": "user",
"parts": [
{
"text": "Can you list the files in the src directory? I want to make sure I haven't missed anything."
}
]
},
{
"role": "model",
"parts": [
{
"functionCall": {
"name": "ls",
"args": {
"path": "src/"
}
}
}
]
},
{
"role": "user",
"parts": [
{
"functionResponse": {
"name": "ls",
"response": {
"output": "src/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
}
}
}
]
},
{
"role": "model",
"parts": [
{
"text": "Sure, here are the files in src/:\nsrc/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
}
]
},
{
"role": "user",
"parts": [
{
"text": "What was the most recent change committed to the repo?"
}
]
},
{
"role": "model",
"parts": [
{
"text": "The latest commit is:\n```\ncommit 0z2ta5\nAuthor: Christian <gundermanc@google.com>\nDate: Mon Feb 2 14:00:00 2026 -0800\n\n perf: optimize lexer lookup table\n```"
}
]
},
{
"role": "user",
"parts": [
{
"text": "I was thinking about the handling variadic functions in the calling convention. What's your take?"
}
]
},
{
"role": "model",
"parts": [
{
"text": "That's a valid concern. Given our current architecture in GigaC, we could definitely improve that area. I recommend we prioritize correctness first, then profile to see if it's a bottleneck."
}
]
},
{
"role": "user",
"parts": [
{
"text": "How is the test coverage looking lately?"
}
]
},
{
"role": "model",
"parts": [
{
"text": "We're making good progress. We now have 19 unit tests passing across all modules."
}
]
}
],
"questions": [
{
"id": "lexer-enum",
"prompt": "What are the first 3 enum values in the TokenType definition for the Lexer?",
"expectedSubstring": "INT, KEYWORD, IDENTIFIER"
},
{
"id": "lexer-constraint",
"prompt": "What is the primary constraint regarding regex libraries in the lexer?",
"expectedSubstring": "no third-party regex libraries"
},
{
"id": "spatial-lexer-path",
"prompt": "Where is the core lexer source file located?",
"expectedSubstring": "src/core/scanner/main.ts"
},
{
"id": "ast-interface",
"prompt": "What are the allowed 'type' values in the AstNode interface?",
"expectedSubstring": "Program' | 'Function' | 'Return' | 'Constant"
},
{
"id": "bug-1-description",
"prompt": "What was Bug #404 about?",
"expectedSubstring": "multi-line block comment"
},
{
"id": "secret-alpha",
"prompt": "What is the secret staging environment access code?",
"expectedSubstring": "STAGING_READY_2026"
},
{
"id": "secret-beta",
"prompt": "What is the production deployment key?",
"expectedSubstring": "PROD_DEPLOY_KEY_XYZ"
},
{
"id": "codegen-header",
"prompt": "What exact comment line should the code generator emit at the start of every file?",
"expectedSubstring": "; Generated by GigaC Compiler v0.1"
},
{
"id": "current-arch",
"prompt": "What is the final target architecture we decided on?",
"expectedSubstring": "ARM64"
},
{
"id": "final-name",
"prompt": "What is the final name of our project after the rename?",
"expectedSubstring": "GigaC"
}
]
}
+36 -8
View File
@@ -11,7 +11,7 @@
"packages/*"
],
"dependencies": {
"ink": "npm:@jrichman/ink@6.6.9",
"ink": "npm:@jrichman/ink@6.6.8",
"latest-version": "^9.0.0",
"node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
@@ -447,7 +447,8 @@
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
"license": "(Apache-2.0 AND BSD-3-Clause)",
"peer": true
},
"node_modules/@bundled-es-modules/cookie": {
"version": "2.0.1",
@@ -1450,6 +1451,7 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.7.13",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -2156,6 +2158,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2336,6 +2339,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2385,6 +2389,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2759,6 +2764,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2792,6 +2798,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0"
@@ -2846,6 +2853,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0",
@@ -4082,6 +4090,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4356,6 +4365,7 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5229,6 +5239,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7369,7 +7380,8 @@
"version": "0.0.1581282",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
"integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7953,6 +7965,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8470,6 +8483,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -9782,6 +9796,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -10056,10 +10071,11 @@
},
"node_modules/ink": {
"name": "@jrichman/ink",
"version": "6.6.9",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz",
"integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==",
"version": "6.6.8",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.8.tgz",
"integrity": "sha512-099iGdvWVIM2ivc3NEWyMF7FT06aLmrx1gMGI02ZYB4wLIFn0v/KQl6+20xEwcM6gyzj8Y8842Sf0UH2z0oTDw==",
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-escapes": "^7.0.0",
"ansi-styles": "^6.2.3",
@@ -13833,6 +13849,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13843,6 +13860,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -15992,6 +16010,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16214,7 +16233,8 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16222,6 +16242,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16387,6 +16408,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16609,6 +16631,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -16722,6 +16745,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16734,6 +16758,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -17381,6 +17406,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -17533,7 +17559,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.6.9",
"ink": "npm:@jrichman/ink@6.6.8",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -17824,6 +17850,7 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -17927,6 +17954,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
+2 -2
View File
@@ -73,7 +73,7 @@
"pre-commit": "node scripts/pre-commit.js"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.6.9",
"ink": "npm:@jrichman/ink@6.6.8",
"wrap-ansi": "9.0.2",
"cliui": {
"wrap-ansi": "7.0.0"
@@ -142,7 +142,7 @@
"yargs": "^17.7.2"
},
"dependencies": {
"ink": "npm:@jrichman/ink@6.6.9",
"ink": "npm:@jrichman/ink@6.6.8",
"latest-version": "^9.0.0",
"node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
+31 -149
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn } from 'node:child_process';
import os from 'node:os';
import v8 from 'node:v8';
import { main } from './src/gemini.js';
import { FatalError, writeToStderr } from '@google/gemini-cli-core';
import { runExitCleanup } from './src/utils/cleanup.js';
// --- Global Entry Point ---
@@ -28,162 +28,44 @@ process.on('uncaughtException', (error) => {
// For other errors, we rely on the default behavior, but since we attached a listener,
// we must manually replicate it.
if (error instanceof Error) {
process.stderr.write(error.stack + '\n');
writeToStderr(error.stack + '\n');
} else {
process.stderr.write(String(error) + '\n');
writeToStderr(String(error) + '\n');
}
process.exit(1);
});
async function getMemoryNodeArgs(): Promise<string[]> {
let autoConfigureMemory = true;
main().catch(async (error) => {
// Set a timeout to force exit if cleanup hangs
const cleanupTimeout = setTimeout(() => {
writeToStderr('Cleanup timed out, forcing exit...\n');
process.exit(1);
}, 5000);
try {
const { readFileSync } = await import('node:fs');
const { join } = await import('node:path');
// Respect GEMINI_CLI_HOME environment variable, falling back to os.homedir()
const baseDir =
process.env['GEMINI_CLI_HOME'] || join(os.homedir(), '.gemini');
const settingsPath = join(baseDir, 'settings.json');
const rawSettings = readFileSync(settingsPath, 'utf8');
const settings = JSON.parse(rawSettings);
if (settings?.advanced?.autoConfigureMemory === false) {
autoConfigureMemory = false;
}
} catch {
// ignore
}
if (autoConfigureMemory) {
const totalMemoryMB = os.totalmem() / (1024 * 1024);
const heapStats = v8.getHeapStatistics();
const currentMaxOldSpaceSizeMb = Math.floor(
heapStats.heap_size_limit / 1024 / 1024,
await runExitCleanup();
} catch (cleanupError) {
writeToStderr(
`Error during final cleanup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`,
);
const targetMaxOldSpaceSizeInMB = Math.floor(totalMemoryMB * 0.5);
if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) {
return [`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`];
}
} finally {
clearTimeout(cleanupTimeout);
}
return [];
}
async function run() {
if (!process.env['GEMINI_CLI_NO_RELAUNCH'] && !process.env['SANDBOX']) {
// --- Lightweight Parent Process / Daemon ---
// We avoid importing heavy dependencies here to save ~1.5s of startup time.
const nodeArgs: string[] = [...process.execArgv];
const scriptArgs = process.argv.slice(2);
const memoryArgs = await getMemoryNodeArgs();
nodeArgs.push(...memoryArgs);
const script = process.argv[1];
nodeArgs.push(script);
nodeArgs.push(...scriptArgs);
const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' };
const RELAUNCH_EXIT_CODE = 199;
let latestAdminSettings: unknown = undefined;
// Prevent the parent process from exiting prematurely on signals.
// The child process will receive the same signals and handle its own cleanup.
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
process.on(sig as NodeJS.Signals, () => {});
if (error instanceof FatalError) {
let errorMessage = error.message;
if (!process.env['NO_COLOR']) {
errorMessage = `\x1b[31m${errorMessage}\x1b[0m`;
}
writeToStderr(errorMessage + '\n');
process.exit(error.exitCode);
}
const runner = () => {
process.stdin.pause();
const child = spawn(process.execPath, nodeArgs, {
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
env: newEnv,
});
if (latestAdminSettings) {
child.send({ type: 'admin-settings', settings: latestAdminSettings });
}
child.on('message', (msg: { type?: string; settings?: unknown }) => {
if (msg.type === 'admin-settings-update' && msg.settings) {
latestAdminSettings = msg.settings;
}
});
return new Promise<number>((resolve) => {
child.on('error', (err) => {
process.stderr.write(
'Error: Failed to start child process: ' + err.message + '\n',
);
resolve(1);
});
child.on('close', (code) => {
process.stdin.resume();
resolve(code ?? 1);
});
});
};
while (true) {
try {
const exitCode = await runner();
if (exitCode !== RELAUNCH_EXIT_CODE) {
process.exit(exitCode);
}
} catch (error: unknown) {
process.stdin.resume();
process.stderr.write(
`Fatal error: Failed to relaunch the CLI process.\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
);
process.exit(1);
}
}
writeToStderr('An unexpected critical error occurred:');
if (error instanceof Error) {
writeToStderr(error.stack + '\n');
} else {
// --- Heavy Child Process ---
// Now we can safely import everything.
const { main } = await import('./src/gemini.js');
const { FatalError, writeToStderr } = await import(
'@google/gemini-cli-core'
);
const { runExitCleanup } = await import('./src/utils/cleanup.js');
main().catch(async (error: unknown) => {
// Set a timeout to force exit if cleanup hangs
const cleanupTimeout = setTimeout(() => {
writeToStderr('Cleanup timed out, forcing exit...\n');
process.exit(1);
}, 5000);
try {
await runExitCleanup();
} catch (cleanupError: unknown) {
writeToStderr(
`Error during final cleanup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`,
);
} finally {
clearTimeout(cleanupTimeout);
}
if (error instanceof FatalError) {
let errorMessage = error.message;
if (!process.env['NO_COLOR']) {
errorMessage = `\x1b[31m${errorMessage}\x1b[0m`;
}
writeToStderr(errorMessage + '\n');
process.exit(error.exitCode);
}
writeToStderr('An unexpected critical error occurred:');
if (error instanceof Error) {
writeToStderr(error.stack + '\n');
} else {
writeToStderr(String(error) + '\n');
}
process.exit(1);
});
writeToStderr(String(error) + '\n');
}
}
run();
process.exit(1);
});
+1 -1
View File
@@ -49,7 +49,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.6.9",
"ink": "npm:@jrichman/ink@6.6.8",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
+1 -1
View File
@@ -372,7 +372,7 @@ export class GeminiAgent {
mcpServers,
);
const sessionSelector = new SessionSelector(config.storage);
const sessionSelector = new SessionSelector(config);
const { sessionData, sessionPath } =
await sessionSelector.resolveSession(sessionId);
+1 -2
View File
@@ -1907,8 +1907,7 @@ const SETTINGS_SCHEMA = {
category: 'Advanced',
requiresRestart: true,
default: true,
description:
'Automatically configure Node.js memory limits. Note: Because memory is allocated during the initial process boot, this setting is only read from the global user settings file and ignores workspace-level overrides.',
description: 'Automatically configure Node.js memory limits',
showInDialog: true,
},
dnsResolutionOrder: {
+43 -44
View File
@@ -13,7 +13,7 @@ import {
type OutputPayload,
type ConsoleLogPayload,
type UserFeedbackPayload,
createSessionId,
sessionId,
logUserPrompt,
AuthType,
UserPromptEvent,
@@ -33,7 +33,6 @@ import {
type AdminControlsSettings,
debugLogger,
isHeadlessMode,
Storage,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments } from './config/config.js';
@@ -81,7 +80,10 @@ import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { appEvents, AppEvent } from './utils/events.js';
import { SessionError, SessionSelector } from './utils/sessionUtils.js';
import { relaunchOnExitCode } from './utils/relaunch.js';
import {
relaunchAppInChildProcess,
relaunchOnExitCode,
} from './utils/relaunch.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { deleteSession, listSessions } from './utils/sessions.js';
import { createPolicyUpdater } from './config/policy.js';
@@ -183,39 +185,6 @@ ${reason.stack}`
});
}
export async function resolveSessionId(resumeArg: string | undefined): Promise<{
sessionId: string;
resumedSessionData?: ResumedSessionData;
}> {
if (!resumeArg) {
return { sessionId: createSessionId() };
}
const storage = new Storage(process.cwd());
await storage.initialize();
try {
const { sessionData, sessionPath } = await new SessionSelector(
storage,
).resolveSession(resumeArg);
return {
sessionId: sessionData.sessionId,
resumedSessionData: { conversation: sessionData, filePath: sessionPath },
};
} catch (error) {
if (error instanceof SessionError && error.code === 'NO_SESSIONS_FOUND') {
coreEvents.emitFeedback('warning', error.message);
return { sessionId: createSessionId() };
}
coreEvents.emitFeedback(
'error',
`Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
await runExitCleanup();
process.exit(ExitCodes.FATAL_INPUT_ERROR);
}
}
export async function startInteractiveUI(
config: Config,
settings: LoadedSettings,
@@ -311,8 +280,6 @@ export async function main() {
const argv = await argvPromise;
const { sessionId, resumedSessionData } = await resolveSessionId(argv.resume);
if (
(argv.allowedTools && argv.allowedTools.length > 0) ||
(settings.merged.tools?.allowed && settings.merged.tools.allowed.length > 0)
@@ -436,12 +403,6 @@ export async function main() {
// Set remote admin settings if returned from CCPA.
if (remoteAdminSettings) {
settings.setRemoteAdminSettings(remoteAdminSettings);
if (process.send) {
process.send({
type: 'admin-settings-update',
settings: remoteAdminSettings,
});
}
}
// Run deferred command now that we have admin settings.
@@ -499,6 +460,10 @@ export async function main() {
);
await runExitCleanup();
process.exit(ExitCodes.SUCCESS);
} else {
// Relaunch app so we always have a child process that can be internally
// restarted if needed.
await relaunchAppInChildProcess(memoryArgs, [], remoteAdminSettings);
}
}
@@ -634,6 +599,40 @@ export async function main() {
})),
];
// Handle --resume flag
let resumedSessionData: ResumedSessionData | undefined = undefined;
if (argv.resume) {
const sessionSelector = new SessionSelector(config);
try {
const result = await sessionSelector.resolveSession(argv.resume);
resumedSessionData = {
conversation: result.sessionData,
filePath: result.sessionPath,
};
// Use the existing session ID to continue recording to the same session
config.setSessionId(resumedSessionData.conversation.sessionId);
} catch (error) {
if (
error instanceof SessionError &&
error.code === 'NO_SESSIONS_FOUND'
) {
// No sessions to resume — start a fresh session with a warning
startupWarnings.push({
id: 'resume-no-sessions',
message: error.message,
priority: WarningPriority.High,
});
} else {
coreEvents.emitFeedback(
'error',
`Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
await runExitCleanup();
process.exit(ExitCodes.FATAL_INPUT_ERROR);
}
}
}
cliStartupHandle?.end();
// Render UI, passing necessary config values. Check that there is no command line question.
-3
View File
@@ -73,7 +73,6 @@ vi.mock('./config/config.js', () => ({
getSandbox: vi.fn(() => false),
getQuestion: vi.fn(() => ''),
isInteractive: () => false,
getSessionId: vi.fn().mockReturnValue('test-session-id'),
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
} as unknown as Config),
parseArguments: vi.fn().mockResolvedValue({}),
@@ -214,7 +213,6 @@ describe('gemini.tsx main function cleanup', () => {
getSandbox: vi.fn(() => false),
getDebugMode: vi.fn(() => false),
getPolicyEngine: vi.fn(),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getMessageBus: () => ({ subscribe: vi.fn() }),
getEnableHooks: vi.fn(() => false),
getHookSystem: () => undefined,
@@ -275,7 +273,6 @@ describe('gemini.tsx main function cleanup', () => {
vi.mocked(loadCliConfig).mockResolvedValue(
buildMockConfig({
getHookSystem: vi.fn(() => mockHookSystem),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
}),
);
+1 -1
View File
@@ -107,7 +107,7 @@ export async function startInteractiveUI(
<TerminalProvider>
<ScrollProvider>
<OverflowProvider>
<SessionStatsProvider sessionId={config.getSessionId()}>
<SessionStatsProvider>
<VimModeProvider>
<AppContainer
config={config}
+1 -1
View File
@@ -731,7 +731,7 @@ export const renderWithProviders = async (
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider>
<ShellFocusContext.Provider value={shellFocus}>
<SessionStatsProvider sessionId={config.getSessionId()}>
<SessionStatsProvider>
<StreamingContext.Provider
value={finalUiState.streamingState}
>
+1 -1
View File
@@ -444,7 +444,7 @@ export const AppContainer = (props: AppContainerProps) => {
const [isConfigInitialized, setConfigInitialized] = useState(false);
const logger = useLogger(config);
const logger = useLogger(config.storage);
const { inputHistory, addInput, initializeFromLogger } =
useInputHistoryStore();
@@ -14,8 +14,7 @@
<text x="0" y="19" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
<text x="0" y="53" fill="#333333" textLength="891" lengthAdjust="spacingAndGlyphs">╭─────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#ffffaf" textLength="63" lengthAdjust="spacingAndGlyphs" font-weight="bold">? Edit </text>
<text x="81" y="70" fill="#ffffff" textLength="783" lengthAdjust="spacingAndGlyphs">packages/.../InputPrompt.tsx: return kittyProtocolSupporte... =&gt; return kittyProto…</text>
<text x="18" y="70" fill="#ffffaf" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">? Edit</text>
<text x="882" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="87" fill="#333333" textLength="855" lengthAdjust="spacingAndGlyphs">╭─────────────────────────────────────────────────────────────────────────────────────────────╮</text>

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

@@ -5,7 +5,7 @@ exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation bo
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
╭─────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ? Edit packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto…
│ ? Edit
│ ╭─────────────────────────────────────────────────────────────────────────────────────────────╮ │
│ │ ... first 42 lines hidden (Ctrl+O to show) ... │ │
│ │ 43 const line43 = true; │ │
@@ -9,7 +9,7 @@ import open from 'open';
import path from 'node:path';
import { bugCommand } from './bugCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { getVersion, type Config } from '@google/gemini-cli-core';
import { getVersion } from '@google/gemini-cli-core';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatBytes } from '../utils/formatters.js';
@@ -89,8 +89,7 @@ describe('bugCommand', () => {
getBugCommand: () => undefined,
getIdeMode: () => true,
getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
} as unknown as Config,
},
geminiClient: {
getChat: () => ({
getHistory: () => [],
@@ -138,8 +137,7 @@ describe('bugCommand', () => {
storage: {
getProjectTempDir: () => '/tmp/gemini',
},
getSessionId: vi.fn().mockReturnValue('test-session-id'),
} as unknown as Config,
},
geminiClient: {
getChat: () => ({
getHistory: () => history,
@@ -184,8 +182,7 @@ describe('bugCommand', () => {
getBugCommand: () => ({ urlTemplate: customTemplate }),
getIdeMode: () => true,
getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
} as unknown as Config,
},
geminiClient: {
getChat: () => ({
getHistory: () => [],
+2 -1
View File
@@ -16,6 +16,7 @@ import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatBytes } from '../utils/formatters.js';
import {
IdeClient,
sessionId,
getVersion,
INITIAL_HISTORY_LENGTH,
debugLogger,
@@ -58,7 +59,7 @@ export const bugCommand: SlashCommand = {
let info = `
* **CLI Version:** ${cliVersion}
* **Git Commit:** ${GIT_COMMIT_INFO}
* **Session ID:** ${config?.getSessionId() || 'Unknown'}
* **Session ID:** ${sessionId}
* **Operating System:** ${osVersion}
* **Sandbox Environment:** ${sandboxEnv}
* **Model Version:** ${modelVersion}
@@ -158,7 +158,6 @@ Implement a comprehensive authentication system with multiple providers.
getIdeMode: () => false,
isTrustedFolder: () => true,
getPreferredEditor: () => undefined,
getSessionId: () => 'test-session-id',
storage: {
getPlansDir: () => mockPlansDir,
},
@@ -465,7 +464,6 @@ Implement a comprehensive authentication system with multiple providers.
getTargetDir: () => mockTargetDir,
getIdeMode: () => false,
isTrustedFolder: () => true,
getSessionId: () => 'test-session-id',
storage: {
getPlansDir: () => mockPlansDir,
},
@@ -82,7 +82,6 @@ const mockConfigPlain = {
getExtensionRegistryURI: () => undefined,
getContentGeneratorConfig: () => ({ authType: undefined }),
getSandboxEnabled: () => false,
getSessionId: () => 'test-session-id',
};
const mockConfig = mockConfigPlain as unknown as Config;
@@ -124,7 +124,7 @@ describe('<HistoryItemDisplay />', () => {
duration: '1s',
};
const { lastFrame, unmount } = await renderWithProviders(
<SessionStatsProvider sessionId="test-session-id">
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
@@ -157,7 +157,7 @@ describe('<HistoryItemDisplay />', () => {
type: 'model_stats',
};
const { lastFrame, unmount } = await renderWithProviders(
<SessionStatsProvider sessionId="test-session-id">
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
@@ -173,7 +173,7 @@ describe('<HistoryItemDisplay />', () => {
type: 'tool_stats',
};
const { lastFrame, unmount } = await renderWithProviders(
<SessionStatsProvider sessionId="test-session-id">
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
@@ -190,7 +190,7 @@ describe('<HistoryItemDisplay />', () => {
duration: '1s',
};
const { lastFrame, unmount } = await renderWithProviders(
<SessionStatsProvider sessionId="test-session-id">
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
@@ -647,22 +647,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
{ isActive: focus },
);
// Intentional memory leak for debugging
const leakRef = useRef<number[][]>([]);
const handleInput = useCallback(
(key: Key) => {
// INTENTIONAL LEAK: If the user presses 'x', allocate ~1GB
if (key.name === 'x') {
// Create 100 chunks of ~10MB arrays
for (let i = 0; i < 100; i++) {
leakRef.current.push(
new Array((10 * 1024 * 1024) / 8).fill(Math.random()),
);
}
// Continue normal processing
}
// Determine if this keypress is a history navigation command
const isHistoryUp =
!shellModeActive &&
@@ -86,7 +86,6 @@ describe('<ModelDialog />', () => {
getProModelNoAccess: mockGetProModelNoAccess,
getProModelNoAccessSync: mockGetProModelNoAccessSync,
getLastRetrievedQuota: () => ({ buckets: [] }),
getSessionId: () => 'test-session-id',
};
beforeEach(() => {
@@ -44,7 +44,7 @@ enum TerminalKeys {
LEFT_ARROW = '\u001B[D',
RIGHT_ARROW = '\u001B[C',
ESCAPE = '\u001B',
BACKSPACE = '\x7f',
BACKSPACE = '\u0008',
CTRL_P = '\u0010',
CTRL_N = '\u000E',
}
@@ -55,7 +55,6 @@ describe('ToolConfirmationQueue', () => {
getFileSystemService: () => ({
readFile: vi.fn().mockResolvedValue('Plan content'),
}),
getSessionId: () => 'test-session-id',
storage: {
getPlansDir: () => '/mock/temp/plans',
},
@@ -67,44 +66,6 @@ describe('ToolConfirmationQueue', () => {
vi.clearAllMocks();
});
it('explicitly renders the tool description (containing filename) for edit confirmations', async () => {
const confirmingTool = {
tool: {
callId: 'call-1',
name: 'Edit',
description: 'Editing src/main.ts',
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'edit' as const,
title: 'Confirm edit',
fileName: 'main.ts',
filePath: '/src/main.ts',
fileDiff: '--- a/main.ts\n+++ b/main.ts\n@@ -1 +1 @@\n-old\n+new',
originalContent: 'old',
newContent: 'new',
},
},
index: 1,
total: 1,
};
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>,
{
config: mockConfig,
uiState: {
terminalWidth: 80,
},
},
);
const output = lastFrame();
expect(output).toContain('Editing src/main.ts');
unmount();
});
it('renders the confirming tool with progress indicator', async () => {
const confirmingTool = {
tool: {
@@ -98,9 +98,9 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
<Box flexDirection="row" flexShrink={1} overflow="hidden">
<Text color={theme.status.warning} bold>
? {toolLabel}
{!!tool.description && ' '}
{!isEdit && !!tool.description && ' '}
</Text>
{!!tool.description && (
{!isEdit && !!tool.description && (
<Box flexShrink={1} overflow="hidden">
<Text color={theme.text.primary} wrap="truncate-end">
{tool.description}
@@ -6,8 +6,7 @@
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#333333" textLength="720" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#ffffaf" textLength="99" lengthAdjust="spacingAndGlyphs" font-weight="bold">? replace </text>
<text x="117" y="19" fill="#ffffff" textLength="234" lengthAdjust="spacingAndGlyphs">Replaces content in a file</text>
<text x="18" y="19" fill="#ffffaf" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">? replace</text>
<text x="711" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="36" fill="#333333" textLength="684" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────╮</text>

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

@@ -2,7 +2,7 @@
exports[`ToolConfirmationQueue > calculates availableContentHeight based on availableTerminalHeight from UI state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ? replace edit file
│ ? replace
│ ╭──────────────────────────────────────────────────────────────────────────╮ │
│ ╰─... 48 hidden (Ctrl+O) ...───────────────────────────────────────────────╯ │
│ Apply this change? │
@@ -17,7 +17,7 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
exports[`ToolConfirmationQueue > does not render expansion hint when constrainHeight is false 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ? replace edit file
│ ? replace
│ ╭──────────────────────────────────────────────────────────────────────────╮ │
│ │ │ │
│ │ No changes detected. │ │
@@ -63,7 +63,7 @@ exports[`ToolConfirmationQueue > height allocation and layout > should handle se
exports[`ToolConfirmationQueue > height allocation and layout > should render the full queue wrapper with borders and content for large edit diffs 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ? replace Replaces content in a file
│ ? replace
│ ╭──────────────────────────────────────────────────────────────────────────╮ │
│ │ ... 13 hidden (Ctrl+O) ... │ │
│ │ 7 + const newLine7 = true; │ │
@@ -34,28 +34,6 @@ describe('DenseToolMessage', () => {
terminalWidth: 80,
};
it('explicitly renders the filename in the header for FileDiff results', async () => {
const fileDiff: FileDiff = {
fileName: 'test-file.ts',
filePath: '/test-file.ts',
fileDiff:
'--- a/test-file.ts\n+++ b/test-file.ts\n@@ -1 +1 @@\n-old\n+new',
originalContent: 'old',
newContent: 'new',
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="Edit"
resultDisplay={fileDiff as unknown as ToolResultDisplay}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('test-file.ts');
});
it('renders correctly for a successful string result', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage {...defaultProps} />,
@@ -357,8 +335,9 @@ describe('DenseToolMessage', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('→ Found 2 matches');
// Matches should no longer be rendered in dense mode to keep it compact
expect(output).not.toContain('file1.ts:10: match 1');
// Matches are rendered in a secondary list for high-signal summaries
expect(output).toContain('file1.ts:10: match 1');
expect(output).toContain('file2.ts:20: match 2');
expect(output).toMatchSnapshot();
});
@@ -399,8 +378,9 @@ describe('DenseToolMessage', () => {
const output = lastFrame();
expect(output).toContain('Attempting to read files from **/*.ts');
expect(output).toContain('→ Read 3 file(s) (1 ignored)');
// File lists should no longer be rendered in dense mode
expect(output).not.toContain('file1.ts');
expect(output).toContain('file1.ts');
expect(output).toContain('file2.ts');
expect(output).toContain('file3.ts');
expect(output).toMatchSnapshot();
});
@@ -475,28 +455,6 @@ describe('DenseToolMessage', () => {
expect(output).toMatchSnapshot();
});
it('truncates long description but preserves tool name (< 25 chars)', async () => {
const longDescription =
'This is a very long description that should definitely be truncated because it exceeds the available terminal width and we want to see how it behaves.';
const toolName = 'tool-name-is-24-chars-!!'; // Exactly 24 chars
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
name={toolName}
description={longDescription}
terminalWidth={50} // Narrow width to force truncation
/>,
);
await waitUntilReady();
const output = lastFrame();
// Tool name should be fully present (it plus one space is exactly 25, fitting the maxWidth)
expect(output).toContain(toolName);
// Description should be present but truncated
expect(output).toContain('This is a');
expect(output).toMatchSnapshot();
});
describe('Toggleable Diff View (Alternate Buffer)', () => {
const diffResult: FileDiff = {
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+new line',
@@ -72,6 +72,27 @@ const hasPayload = (res: unknown): res is PayloadResult => {
return typeof value === 'string';
};
const RenderItemsList: React.FC<{
items?: string[];
maxVisible?: number;
}> = ({ items, maxVisible = 20 }) => {
if (!items || items.length === 0) return null;
return (
<Box flexDirection="column">
{items.slice(0, maxVisible).map((item, i) => (
<Text key={i} color={theme.text.secondary}>
{item}
</Text>
))}
{items.length > maxVisible && (
<Text color={theme.text.secondary}>
... and {items.length - maxVisible} more
</Text>
)}
</Box>
);
};
function getFileOpData(
diff: FileDiff,
status: CoreToolCallStatus,
@@ -167,6 +188,8 @@ function getFileOpData(
}
function getReadManyFilesData(result: ReadManyFilesResult): ViewParts {
const items = result.files ?? [];
const maxVisible = 10;
const includePatterns = result.include?.join(', ') ?? '';
const description = (
<Text color={theme.text.secondary} wrap="truncate-end">
@@ -175,12 +198,18 @@ function getReadManyFilesData(result: ReadManyFilesResult): ViewParts {
);
const skippedCount = result.skipped?.length ?? 0;
const summaryStr = `Read ${result.files.length} file(s)${
const summaryStr = `Read ${items.length} file(s)${
skippedCount > 0 ? ` (${skippedCount} ignored)` : ''
}`;
const summary = <Text color={theme.text.accent}> {summaryStr}</Text>;
const hasItems = items.length > 0;
const payload = hasItems ? (
<Box flexDirection="column" marginLeft={2}>
{hasItems && <RenderItemsList items={items} maxVisible={maxVisible} />}
</Box>
) : undefined;
return { description, summary, payload: undefined };
return { description, summary, payload };
}
function getListDirectoryData(
@@ -229,11 +258,20 @@ function getGenericSuccessData(
</Text>
);
} else if (isGrepResult(resultDisplay)) {
summary = (
<Text color={theme.text.accent} wrap="truncate-end">
{resultDisplay.summary}
</Text>
);
summary = <Text color={theme.text.accent}> {resultDisplay.summary}</Text>;
const matches = resultDisplay.matches;
if (matches.length > 0) {
payload = (
<Box flexDirection="column" marginLeft={2}>
<RenderItemsList
items={matches.map(
(m) => `${m.filePath}:${m.lineNumber}: ${m.line.trim()}`,
)}
maxVisible={10}
/>
</Box>
);
}
} else if (isTodoList(resultDisplay)) {
summary = (
<Text color={theme.text.accent} wrap="wrap">
@@ -450,18 +488,15 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
return (
<Box flexDirection="column">
<Box marginLeft={2} flexDirection="row" flexWrap="wrap">
<Box flexDirection="row" flexShrink={1}>
<ToolStatusIndicator status={status} name={name} />
<Box maxWidth={25} flexShrink={0} flexGrow={0}>
<Text color={theme.text.primary} bold wrap="truncate-end">
{name}{' '}
</Text>
</Box>
<Box marginLeft={1} flexShrink={1} flexGrow={0}>
{description}
</Box>
<ToolStatusIndicator status={status} name={name} />
<Box maxWidth={25} flexShrink={1} flexGrow={0}>
<Text color={theme.text.primary} bold wrap="truncate-end">
{name}{' '}
</Text>
</Box>
<Box marginLeft={1} flexShrink={1} flexGrow={0}>
{description}
</Box>
{summary && (
<Box
key="tool-summary"
@@ -35,6 +35,8 @@ import {
WRITE_FILE_DISPLAY_NAME,
READ_MANY_FILES_DISPLAY_NAME,
isFileDiff,
isGrepResult,
isListResult,
} from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
@@ -79,6 +81,15 @@ export const hasDensePayload = (tool: IndividualToolCallDisplay): boolean => {
// TODO(24053): Usage of type guards makes this class too aware of internals
if (isFileDiff(res)) return true;
if (tool.confirmationDetails?.type === 'edit') return true;
if (isGrepResult(res) && res.matches.length > 0) return true;
// ReadManyFilesResult check (has 'include' and 'files')
if (isListResult(res) && 'include' in res) {
const includeProp = (res as { include?: unknown }).include;
if (Array.isArray(includeProp) && res.files.length > 0) {
return true;
}
}
// Generic summary/payload pattern
if (
@@ -51,6 +51,10 @@ exports[`DenseToolMessage > renders correctly for Errored Edit tool 1`] = `
exports[`DenseToolMessage > renders correctly for ReadManyFiles results 1`] = `
" ✓ test-tool Attempting to read files from **/*.ts → Read 3 file(s) (1 ignored)
file1.ts
file2.ts
file3.ts
"
`;
@@ -106,6 +110,9 @@ exports[`DenseToolMessage > renders correctly for file diff results with stats 1
exports[`DenseToolMessage > renders correctly for grep results 1`] = `
" ✓ test-tool Test description → Found 2 matches
file1.ts:10: match 1
file2.ts:20: match 2
"
`;
@@ -129,12 +136,6 @@ exports[`DenseToolMessage > renders generic output message for unknown object re
"
`;
exports[`DenseToolMessage > truncates long description but preserves tool name (< 25 chars) 1`] = `
" ✓ tool-name-is-24-chars-!! This is a very long description that should definitely be truncated …
→ Success result
"
`;
exports[`DenseToolMessage > truncates long string results 1`] = `
" ✓ test-tool Test description
→ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA…
@@ -24,7 +24,7 @@ enum TerminalKeys {
LEFT_ARROW = '\u001B[D',
RIGHT_ARROW = '\u001B[C',
ESCAPE = '\u001B',
BACKSPACE = '\x7f',
BACKSPACE = '\u0008',
CTRL_L = '\u000C',
}
@@ -9,17 +9,7 @@ import { act } from 'react';
import { renderHookWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { waitFor } from '../../test-utils/async.js';
import type { Mock } from 'vitest';
import {
vi,
afterAll,
beforeAll,
describe,
it,
expect,
beforeEach,
afterEach,
} from 'vitest';
import { vi, afterAll, beforeAll, type Mock } from 'vitest';
import {
useKeypressContext,
ESC_TIMEOUT,
@@ -441,80 +431,6 @@ describe('KeypressContext', () => {
);
});
describe('Windows Terminal Backspace handling', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('should NOT treat \\b as ctrl when WT_SESSION is NOT present and OS is not Windows_NT', async () => {
vi.stubEnv('WT_SESSION', '');
vi.stubEnv('OS', 'Linux');
const { keyHandler } = await setupKeypressTest();
act(() => {
stdin.write('\b');
});
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'backspace',
ctrl: false,
}),
);
});
it('should treat \\b as ctrl when WT_SESSION IS present (even if not Windows_NT)', async () => {
vi.stubEnv('WT_SESSION', 'some-id');
vi.stubEnv('OS', 'Linux');
const { keyHandler } = await setupKeypressTest();
act(() => {
stdin.write('\b');
});
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'backspace',
ctrl: true,
}),
);
});
it('should treat \\b as ctrl when OS is Windows_NT', async () => {
vi.stubEnv('WT_SESSION', '');
vi.stubEnv('OS', 'Windows_NT');
const { keyHandler } = await setupKeypressTest();
act(() => {
stdin.write('\b');
});
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'backspace',
ctrl: true,
}),
);
});
it('should treat \\x7f as regular backspace regardless of WT_SESSION or OS', async () => {
vi.stubEnv('WT_SESSION', 'some-id');
vi.stubEnv('OS', 'Windows_NT');
const { keyHandler } = await setupKeypressTest();
act(() => {
stdin.write('\x7f');
});
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'backspace',
ctrl: false,
}),
);
});
});
describe('paste mode', () => {
it.each([
{
@@ -651,20 +651,8 @@ function* emitKeys(
// tab
name = 'tab';
alt = escaped;
} else if (ch === '\b') {
// ctrl+h / ctrl+backspace (windows terminals send \x08 for ctrl+backspace)
name = 'backspace';
// In Windows environments, \b is sent for Ctrl+Backspace (standard backspace is translated to \x7f).
// We scope this to Windows/WT_SESSION to avoid breaking other unixes where \b is a plain backspace.
if (
typeof process !== 'undefined' &&
(process.env?.['OS'] === 'Windows_NT' || !!process.env?.['WT_SESSION'])
) {
ctrl = true;
}
alt = escaped;
} else if (ch === '\x7f') {
// backspace
} else if (ch === '\b' || ch === '\x7f') {
// backspace or ctrl+h
name = 'backspace';
alt = escaped;
} else if (ch === ESC) {
@@ -60,7 +60,7 @@ describe('SessionStatsContext', () => {
> = { current: undefined };
const { unmount } = await render(
<SessionStatsProvider sessionId="test-session-id">
<SessionStatsProvider>
<TestHarness contextRef={contextRef} />
</SessionStatsProvider>,
);
@@ -79,7 +79,7 @@ describe('SessionStatsContext', () => {
> = { current: undefined };
const { unmount } = await render(
<SessionStatsProvider sessionId="test-session-id">
<SessionStatsProvider>
<TestHarness contextRef={contextRef} />
</SessionStatsProvider>,
);
@@ -162,7 +162,7 @@ describe('SessionStatsContext', () => {
};
const { unmount } = await render(
<SessionStatsProvider sessionId="test-session-id">
<SessionStatsProvider>
<CountingTestHarness />
</SessionStatsProvider>,
);
@@ -245,7 +245,7 @@ describe('SessionStatsContext', () => {
> = { current: undefined };
const { unmount } = await render(
<SessionStatsProvider sessionId="test-session-id">
<SessionStatsProvider>
<TestHarness contextRef={contextRef} />
</SessionStatsProvider>,
);
@@ -13,13 +13,14 @@ import {
useMemo,
useEffect,
} from 'react';
import type {
SessionMetrics,
ModelMetrics,
RoleMetrics,
ToolCallStats,
} from '@google/gemini-cli-core';
import { uiTelemetryService } from '@google/gemini-cli-core';
import { uiTelemetryService, sessionId } from '@google/gemini-cli-core';
export enum ToolCallDecision {
ACCEPT = 'accept',
@@ -182,10 +183,9 @@ const SessionStatsContext = createContext<SessionStatsContextValue | undefined>(
// --- Provider Component ---
export const SessionStatsProvider: React.FC<{
children: React.ReactNode;
sessionId: string;
}> = ({ children, sessionId }) => {
export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [stats, setStats] = useState<SessionStatsState>({
sessionId,
sessionStartTime: new Date(),
+14 -6
View File
@@ -262,13 +262,14 @@ export const useGeminiStream = (
useStateAndRef<boolean>(true);
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
const { startNewPrompt, getPromptCount } = useSessionStats();
const logger = useLogger(config);
const storage = config.storage;
const logger = useLogger(storage);
const gitService = useMemo(() => {
if (!config.getProjectRoot()) {
return;
}
return new GitService(config.getProjectRoot(), config.storage);
}, [config]);
return new GitService(config.getProjectRoot(), storage);
}, [config, storage]);
useEffect(() => {
const handleRetryAttempt = (payload: RetryAttemptPayload) => {
@@ -1579,7 +1580,6 @@ export const useGeminiStream = (
operation: options?.isContinuation
? GeminiCliOperation.SystemPrompt
: GeminiCliOperation.UserPrompt,
sessionId: config.getSessionId(),
},
async ({ metadata: spanMetadata }) => {
spanMetadata.input = query;
@@ -2105,7 +2105,7 @@ export const useGeminiStream = (
}
if (checkpointsToWrite.size > 0) {
const checkpointDir = config.storage.getProjectTempCheckpointsDir();
const checkpointDir = storage.getProjectTempCheckpointsDir();
try {
await fs.mkdir(checkpointDir, { recursive: true });
for (const [fileName, content] of checkpointsToWrite) {
@@ -2122,7 +2122,15 @@ export const useGeminiStream = (
};
// eslint-disable-next-line @typescript-eslint/no-floating-promises
saveRestorableToolCalls();
}, [toolCalls, config, onDebugMessage, gitService, history, geminiClient]);
}, [
toolCalls,
config,
onDebugMessage,
gitService,
history,
geminiClient,
storage,
]);
const lastOutputTime = Math.max(
lastToolOutputTime,
+31 -4
View File
@@ -8,7 +8,14 @@ import { act } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import { useLogger } from './useLogger.js';
import { Logger, type Storage, type Config } from '@google/gemini-cli-core';
import {
sessionId as globalSessionId,
Logger,
type Storage,
type Config,
} from '@google/gemini-cli-core';
import { ConfigContext } from '../contexts/ConfigContext.js';
import type React from 'react';
let deferredInit: { resolve: (val?: unknown) => void };
@@ -34,15 +41,35 @@ describe('useLogger', () => {
const mockStorage = {} as Storage;
const mockConfig = {
getSessionId: vi.fn().mockReturnValue('active-session-id'),
storage: mockStorage,
} as unknown as Config;
beforeEach(() => {
vi.clearAllMocks();
});
it('should initialize with the sessionId from config', async () => {
const { result } = await renderHook(() => useLogger(mockConfig));
it('should initialize with the global sessionId by default', async () => {
const { result } = await renderHook(() => useLogger(mockStorage));
expect(result.current).toBeNull();
await act(async () => {
deferredInit.resolve();
});
expect(result.current).not.toBeNull();
expect(Logger).toHaveBeenCalledWith(globalSessionId, mockStorage);
});
it('should initialize with the active sessionId from ConfigContext when available', async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ConfigContext.Provider value={mockConfig}>
{children}
</ConfigContext.Provider>
);
const { result } = await renderHook(() => useLogger(mockStorage), {
wrapper,
});
expect(result.current).toBeNull();
+15 -6
View File
@@ -4,17 +4,24 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect } from 'react';
import { Logger, type Config } from '@google/gemini-cli-core';
import { useState, useEffect, useContext } from 'react';
import {
sessionId as globalSessionId,
Logger,
type Storage,
} from '@google/gemini-cli-core';
import { ConfigContext } from '../contexts/ConfigContext.js';
/**
* Hook to manage the logger instance.
*/
export const useLogger = (config: Config): Logger | null => {
export const useLogger = (storage: Storage): Logger | null => {
const [logger, setLogger] = useState<Logger | null>(null);
const config = useContext(ConfigContext);
useEffect(() => {
const newLogger = new Logger(config.getSessionId(), config.storage);
const activeSessionId = config?.getSessionId() ?? globalSessionId;
const newLogger = new Logger(activeSessionId, storage);
/**
* Start async initialization, no need to await. Using await slows down the
@@ -23,9 +30,11 @@ export const useLogger = (config: Config): Logger | null => {
*/
newLogger
.initialize()
.then(() => setLogger(newLogger))
.then(() => {
setLogger(newLogger);
})
.catch(() => {});
}, [config]);
}, [storage, config]);
return logger;
};
+1 -4
View File
@@ -376,10 +376,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
new KeyBinding('ctrl+j'),
],
],
[
Command.OPEN_EXTERNAL_EDITOR,
[new KeyBinding('ctrl+g'), new KeyBinding('ctrl+shift+g')],
],
[Command.OPEN_EXTERNAL_EDITOR, [new KeyBinding('ctrl+g')]],
[Command.DEPRECATED_OPEN_EXTERNAL_EDITOR, [new KeyBinding('ctrl+x')]],
[
Command.PASTE_CLIPBOARD,
+25 -19
View File
@@ -15,7 +15,7 @@ import {
} from './sessionUtils.js';
import {
SESSION_FILE_PREFIX,
type Storage,
type Config,
type MessageRecord,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
@@ -25,17 +25,20 @@ import { randomUUID } from 'node:crypto';
describe('SessionSelector', () => {
let tmpDir: string;
let storage: Storage;
let config: Config;
beforeEach(async () => {
// Create a temporary directory for testing
tmpDir = path.join(process.cwd(), '.tmp-test-sessions');
await fs.mkdir(tmpDir, { recursive: true });
// Mock storage
storage = {
getProjectTempDir: () => tmpDir,
} as Partial<Storage> as Storage;
// Mock config
config = {
storage: {
getProjectTempDir: () => tmpDir,
},
getSessionId: () => 'current-session-id',
} as Partial<Config> as Config;
});
afterEach(async () => {
@@ -101,7 +104,7 @@ describe('SessionSelector', () => {
JSON.stringify(session2, null, 2),
);
const sessionSelector = new SessionSelector(storage);
const sessionSelector = new SessionSelector(config);
// Test resolving by UUID
const result1 = await sessionSelector.resolveSession(sessionId1);
@@ -167,7 +170,7 @@ describe('SessionSelector', () => {
JSON.stringify(session2, null, 2),
);
const sessionSelector = new SessionSelector(storage);
const sessionSelector = new SessionSelector(config);
// Test resolving by index (1-based)
const result1 = await sessionSelector.resolveSession('1');
@@ -231,7 +234,7 @@ describe('SessionSelector', () => {
JSON.stringify(session2, null, 2),
);
const sessionSelector = new SessionSelector(storage);
const sessionSelector = new SessionSelector(config);
// Test resolving latest
const result = await sessionSelector.resolveSession('latest');
@@ -268,7 +271,7 @@ describe('SessionSelector', () => {
JSON.stringify(session, null, 2),
);
const sessionSelector = new SessionSelector(storage);
const sessionSelector = new SessionSelector(config);
// Test resolving by UUID with leading/trailing spaces
const result = await sessionSelector.resolveSession(` ${sessionId} `);
@@ -331,7 +334,7 @@ describe('SessionSelector', () => {
JSON.stringify(sessionDuplicate, null, 2),
);
const sessionSelector = new SessionSelector(storage);
const sessionSelector = new SessionSelector(config);
const sessions = await sessionSelector.listSessions();
expect(sessions.length).toBe(1);
@@ -370,7 +373,7 @@ describe('SessionSelector', () => {
JSON.stringify(session1, null, 2),
);
const sessionSelector = new SessionSelector(storage);
const sessionSelector = new SessionSelector(config);
await expect(
sessionSelector.resolveSession('invalid-uuid'),
@@ -386,11 +389,14 @@ describe('SessionSelector', () => {
const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
const emptyStorage = {
getProjectTempDir: () => tmpDir,
} as Partial<Storage> as Storage;
const emptyConfig = {
storage: {
getProjectTempDir: () => tmpDir,
},
getSessionId: () => 'current-session-id',
} as Partial<Config> as Config;
const sessionSelector = new SessionSelector(emptyStorage);
const sessionSelector = new SessionSelector(emptyConfig);
await expect(sessionSelector.resolveSession('latest')).rejects.toSatisfy(
(error) => {
@@ -463,7 +469,7 @@ describe('SessionSelector', () => {
JSON.stringify(sessionSystemOnly, null, 2),
);
const sessionSelector = new SessionSelector(storage);
const sessionSelector = new SessionSelector(config);
const sessions = await sessionSelector.listSessions();
// Should only list the session with user message
@@ -502,7 +508,7 @@ describe('SessionSelector', () => {
JSON.stringify(sessionGeminiOnly, null, 2),
);
const sessionSelector = new SessionSelector(storage);
const sessionSelector = new SessionSelector(config);
const sessions = await sessionSelector.listSessions();
// Should list the session with gemini message
@@ -568,7 +574,7 @@ describe('SessionSelector', () => {
JSON.stringify(subagentSession, null, 2),
);
const sessionSelector = new SessionSelector(storage);
const sessionSelector = new SessionSelector(config);
const sessions = await sessionSelector.listSessions();
// Should only list the main session
+15 -6
View File
@@ -9,7 +9,7 @@ import {
partListUnionToString,
SESSION_FILE_PREFIX,
CoreToolCallStatus,
type Storage,
type Config,
type ConversationRecord,
type MessageRecord,
} from '@google/gemini-cli-core';
@@ -399,14 +399,17 @@ export const getSessionFiles = async (
* Utility class for session discovery and selection.
*/
export class SessionSelector {
constructor(private storage: Storage) {}
constructor(private config: Config) {}
/**
* Lists all available sessions for the current project.
*/
async listSessions(): Promise<SessionInfo[]> {
const chatsDir = path.join(this.storage.getProjectTempDir(), 'chats');
return getSessionFiles(chatsDir);
const chatsDir = path.join(
this.config.storage.getProjectTempDir(),
'chats',
);
return getSessionFiles(chatsDir, this.config.getSessionId());
}
/**
@@ -449,7 +452,10 @@ export class SessionSelector {
return sortedSessions[index - 1];
}
const chatsDir = path.join(this.storage.getProjectTempDir(), 'chats');
const chatsDir = path.join(
this.config.storage.getProjectTempDir(),
'chats',
);
throw SessionError.invalidSessionIdentifier(trimmedIdentifier, chatsDir);
}
@@ -501,7 +507,10 @@ export class SessionSelector {
private async selectSession(
sessionInfo: SessionInfo,
): Promise<SessionSelectionResult> {
const chatsDir = path.join(this.storage.getProjectTempDir(), 'chats');
const chatsDir = path.join(
this.config.storage.getProjectTempDir(),
'chats',
);
const sessionPath = path.join(chatsDir, sessionInfo.fileName);
try {
+2 -2
View File
@@ -21,7 +21,7 @@ export async function listSessions(config: Config): Promise<void> {
// Generate summary for most recent session if needed
await generateSummary(config);
const sessionSelector = new SessionSelector(config.storage);
const sessionSelector = new SessionSelector(config);
const sessions = await sessionSelector.listSessions();
if (sessions.length === 0) {
@@ -55,7 +55,7 @@ export async function deleteSession(
config: Config,
sessionIndex: string,
): Promise<void> {
const sessionSelector = new SessionSelector(config.storage);
const sessionSelector = new SessionSelector(config);
const sessions = await sessionSelector.listSessions();
if (sessions.length === 0) {
@@ -182,7 +182,6 @@ class SubAgentInvocation extends BaseToolInvocation<AgentInputs, ToolResult> {
{
operation: GeminiCliOperation.AgentCall,
logPrompts: this.context.config.getTelemetryLogPromptsEnabled(),
sessionId: this.context.config.getSessionId(),
attributes: {
[GEN_AI_AGENT_NAME]: this.definition.name,
[GEN_AI_AGENT_DESCRIPTION]: this.definition.description,
+8
View File
@@ -700,6 +700,7 @@ export interface ConfigParameters {
experimentalJitContext?: boolean;
autoDistillation?: boolean;
experimentalMemoryManager?: boolean;
experimentalContextSidecarConfig?: string;
experimentalAgentHistoryTruncation?: boolean;
experimentalAgentHistoryTruncationThreshold?: number;
experimentalAgentHistoryRetainedMessages?: number;
@@ -942,6 +943,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly adminSkillsEnabled: boolean;
private readonly experimentalJitContext: boolean;
private readonly experimentalMemoryManager: boolean;
private readonly experimentalContextSidecarConfig?: string;
private readonly memoryBoundaryMarkers: readonly string[];
private readonly topicUpdateNarration: boolean;
private readonly disableLLMCorrection: boolean;
@@ -1153,6 +1155,8 @@ export class Config implements McpContext, AgentLoopContext {
this.experimentalJitContext = params.experimentalJitContext ?? false;
this.experimentalMemoryManager = params.experimentalMemoryManager ?? false;
this.experimentalContextSidecarConfig =
params.experimentalContextSidecarConfig;
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
this.contextManagement = {
enabled: params.contextManagement?.enabled ?? false,
@@ -2427,6 +2431,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.experimentalMemoryManager;
}
getExperimentalContextSidecarConfig(): string | undefined {
return this.experimentalContextSidecarConfig;
}
getContextManagementConfig(): ContextManagementConfig {
return this.contextManagement;
}
@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { testTruncateProfile } from './testing/testProfile.js';
import {
createSyntheticHistory,
createMockContextConfig,
setupContextComponentTest,
} from './testing/contextTestUtils.js';
describe('ContextManager Sync Pressure Barrier Tests', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should instantly truncate history when maxTokens is exceeded using truncate strategy', async () => {
// 1. Setup
const config = createMockContextConfig();
const { chatHistory, contextManager } = setupContextComponentTest(
config,
testTruncateProfile,
);
// 2. Add System Prompt (Episode 0 - Protected)
chatHistory.set([
{ role: 'user', parts: [{ text: 'System prompt' }] },
{ role: 'model', parts: [{ text: 'Understood.' }] },
]);
// 3. Add massive history that blows past the 150k maxTokens limit
// 20 turns * 10,000 tokens/turn = ~200,000 tokens
const massiveHistory = createSyntheticHistory(20, 35000);
chatHistory.set([...chatHistory.get(), ...massiveHistory]);
// 4. Add the Latest Turn (Protected)
chatHistory.set([
...chatHistory.get(),
{ role: 'user', parts: [{ text: 'Final question.' }] },
{ role: 'model', parts: [{ text: 'Final answer.' }] },
]);
const rawHistoryLength = chatHistory.get().length;
// 5. Project History (Triggers Sync Barrier)
const projection = await contextManager.projectCompressedHistory();
// 6. Assertions
// The barrier should have dropped several older episodes to get under 150k.
expect(projection.length).toBeLessThan(rawHistoryLength);
// Verify Episode 0 (System) is perfectly preserved at the front
expect(projection[0].role).toBe('user');
expect(projection[0].parts![0].text).toBe('System prompt');
// Filter out synthetic Yield nodes (they are model responses without actual tool/text bodies)
const contentNodes = projection.filter(
(p) =>
p.parts && p.parts.some((part) => part.text && part.text !== 'Yield'),
);
// Verify the latest turn is perfectly preserved at the back
const lastUser = contentNodes[contentNodes.length - 2];
const lastModel = contentNodes[contentNodes.length - 1];
expect(lastUser.role).toBe('user');
expect(lastUser.parts![0].text).toBe('Final question.');
expect(lastModel.role).toBe('model');
expect(lastModel.parts![0].text).toBe('Final answer.');
});
});
+158
View File
@@ -0,0 +1,158 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content } from '@google/genai';
import type { AgentChatHistory } from '../core/agentChatHistory.js';
import type { ConcreteNode } from './ir/types.js';
import type { ContextEventBus } from './eventBus.js';
import type { ContextTracer } from './tracer.js';
import type { ContextEnvironment } from './sidecar/environment.js';
import type { SidecarConfig } from './sidecar/types.js';
import type { PipelineOrchestrator } from './sidecar/orchestrator.js';
import { HistoryObserver } from './historyObserver.js';
import { IrProjector } from './ir/projector.js';
import { ContextWorkingBufferImpl } from './sidecar/contextWorkingBuffer.js';
export class ContextManager {
// The master state containing the pristine graph and current active graph.
private buffer: ContextWorkingBufferImpl =
ContextWorkingBufferImpl.initialize([]);
private pristineNodes: readonly ConcreteNode[] = [];
private readonly eventBus: ContextEventBus;
// Internal sub-components
private readonly orchestrator: PipelineOrchestrator;
private readonly historyObserver: HistoryObserver;
constructor(
private readonly sidecar: SidecarConfig,
private readonly env: ContextEnvironment,
private readonly tracer: ContextTracer,
orchestrator: PipelineOrchestrator,
chatHistory: AgentChatHistory,
) {
this.eventBus = env.eventBus;
this.orchestrator = orchestrator;
this.historyObserver = new HistoryObserver(
chatHistory,
this.env.eventBus,
this.tracer,
this.env.tokenCalculator,
this.env.irMapper,
);
this.historyObserver.start();
this.eventBus.onPristineHistoryUpdated((event) => {
this.pristineNodes = event.nodes;
const existingIds = new Set(this.buffer.nodes.map((n) => n.id));
const addedNodes = event.nodes.filter((n) => !existingIds.has(n.id));
if (addedNodes.length > 0) {
this.buffer = this.buffer.appendPristineNodes(addedNodes);
}
this.evaluateTriggers(event.newNodes);
});
}
/**
* Safely stops background workers and clears event listeners.
*/
shutdown() {
this.orchestrator.shutdown();
this.historyObserver.stop();
}
/**
* Evaluates if the current working buffer exceeds configured budget thresholds,
* firing consolidation events if necessary.
*/
private evaluateTriggers(newNodes: Set<string>) {
if (!this.sidecar.budget) return;
if (newNodes.size > 0) {
this.eventBus.emitChunkReceived({
nodes: this.buffer.nodes,
targetNodeIds: newNodes,
});
}
const currentTokens = this.env.tokenCalculator.calculateConcreteListTokens(
this.buffer.nodes,
);
if (currentTokens > this.sidecar.budget.retainedTokens) {
const agedOutNodes = new Set<string>();
let rollingTokens = 0;
// 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];
rollingTokens += this.env.tokenCalculator.calculateConcreteListTokens([
node,
]);
if (rollingTokens > this.sidecar.budget.retainedTokens) {
agedOutNodes.add(node.id);
}
}
if (agedOutNodes.size > 0) {
this.eventBus.emitConsolidationNeeded({
nodes: this.buffer.nodes,
targetDeficit: currentTokens - this.sidecar.budget.retainedTokens,
targetNodeIds: agedOutNodes,
});
}
}
}
/**
* Retrieves the raw, uncompressed Episodic IR graph.
* Useful for internal tool rendering (like the trace viewer).
* Note: This is an expensive, deep clone operation.
*/
getPristineGraph(): readonly ConcreteNode[] {
return [...this.pristineNodes];
}
/**
* Generates a virtual view of the pristine graph, substituting in variants
* up to the configured token budget.
* This is the view that will eventually be projected back to the LLM.
*/
getNodes(): readonly ConcreteNode[] {
return [...this.buffer.nodes];
}
/**
* Executes the final 'gc_backstop' pipeline if necessary, enforcing the token budget,
* and maps the Episodic IR back into a raw Gemini Content[] array for transmission.
* This is the primary method called by the agent framework before sending a request.
*/
async projectCompressedHistory(
activeTaskIds: Set<string> = new Set(),
): Promise<Content[]> {
this.tracer.logEvent(
'ContextManager',
'Starting projection to LLM context',
);
// Apply final GC Backstop pressure barrier synchronously before mapping
const finalHistory = await IrProjector.project(
this.buffer.nodes,
this.orchestrator,
this.sidecar,
this.tracer,
this.env,
activeTaskIds,
);
this.tracer.logEvent('ContextManager', 'Finished projection');
return finalHistory;
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { EventEmitter } from 'node:events';
import type { ConcreteNode } from './ir/types.js';
export interface PristineHistoryUpdatedEvent {
nodes: readonly ConcreteNode[];
newNodes: Set<string>;
}
export interface ContextConsolidationEvent {
nodes: readonly ConcreteNode[];
targetDeficit: number;
targetNodeIds: Set<string>;
}
export interface IrChunkReceivedEvent {
nodes: readonly ConcreteNode[];
targetNodeIds: Set<string>;
}
export class ContextEventBus extends EventEmitter {
emitPristineHistoryUpdated(event: PristineHistoryUpdatedEvent) {
this.emit('PRISTINE_HISTORY_UPDATED', event);
}
onPristineHistoryUpdated(
listener: (event: PristineHistoryUpdatedEvent) => void,
) {
this.on('PRISTINE_HISTORY_UPDATED', listener);
}
emitChunkReceived(event: IrChunkReceivedEvent) {
this.emit('IR_CHUNK_RECEIVED', event);
}
onChunkReceived(listener: (event: IrChunkReceivedEvent) => void) {
this.on('IR_CHUNK_RECEIVED', listener);
}
emitConsolidationNeeded(event: ContextConsolidationEvent) {
this.emit('BUDGET_RETAINED_CROSSED', event);
}
onConsolidationNeeded(listener: (event: ContextConsolidationEvent) => void) {
this.on('BUDGET_RETAINED_CROSSED', listener);
}
}
@@ -0,0 +1,88 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
AgentChatHistory,
HistoryEvent,
} from '../core/agentChatHistory.js';
import type { IrMapper } from './ir/mapper.js';
import type { ContextTokenCalculator } from './utils/contextTokenCalculator.js';
import type { ContextEventBus } from './eventBus.js';
import type { ContextTracer } from './tracer.js';
import type { ConcreteNode } from './ir/types.js';
/**
* Connects the raw AgentChatHistory to the ContextManager.
* It maps raw messages into Episodic Intermediate Representation (IR)
* and evaluates background triggers whenever history changes.
*/
export class HistoryObserver {
private unsubscribeHistory?: () => void;
private seenNodeIds = new Set<string>();
constructor(
private readonly chatHistory: AgentChatHistory,
private readonly eventBus: ContextEventBus,
private readonly tracer: ContextTracer,
private readonly tokenCalculator: ContextTokenCalculator,
private readonly irMapper: IrMapper,
) {}
start() {
if (this.unsubscribeHistory) {
this.unsubscribeHistory();
}
this.unsubscribeHistory = this.chatHistory.subscribe(
(_event: HistoryEvent) => {
// Rebuild the pristine IR graph from the full source history on every change.
// Wait, toIr still returns an Episode[].
// We actually need to map the Episode[] to a flat ConcreteNode[] here to form the 'nodes'.
const pristineEpisodes = this.irMapper.toIr(
this.chatHistory.get(),
this.tokenCalculator,
);
const nodes: ConcreteNode[] = [];
for (const ep of pristineEpisodes) {
if (ep.concreteNodes) {
for (const child of ep.concreteNodes) {
nodes.push(child);
}
}
}
const newNodes = new Set<string>();
for (const node of nodes) {
if (!this.seenNodeIds.has(node.id)) {
newNodes.add(node.id);
this.seenNodeIds.add(node.id);
}
}
this.tracer.logEvent(
'HistoryObserver',
'Rebuilt pristine graph from chat history update',
{ nodesSize: nodes.length, newNodesCount: newNodes.size },
);
this.eventBus.emitPristineHistoryUpdated({
nodes,
newNodes,
});
},
);
}
stop() {
if (this.unsubscribeHistory) {
this.unsubscribeHistory();
this.unsubscribeHistory = undefined;
}
}
}
@@ -0,0 +1,43 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content, Part } from '@google/genai';
import type { ConcreteNode } from './types.js';
export interface IrSerializationWriter {
appendContent(content: Content): void;
appendModelPart(part: Part): void;
appendUserPart(part: Part): void;
flushModelParts(): void;
}
export interface IrNodeBehavior<T extends ConcreteNode = ConcreteNode> {
readonly type: T['type'];
/** Serializes the node into the Gemini Content structure. */
serialize(node: T, writer: IrSerializationWriter): void;
/**
* Generates a structural representation of the node for the purpose
* of estimating its token cost.
*/
getEstimatableParts(node: T): Part[];
}
export class IrNodeBehaviorRegistry {
private readonly behaviors = new Map<string, IrNodeBehavior<ConcreteNode>>();
register<T extends ConcreteNode>(behavior: IrNodeBehavior<T>) {
this.behaviors.set(behavior.type, behavior as IrNodeBehavior<ConcreteNode>);
}
get(type: string): IrNodeBehavior<ConcreteNode> {
const behavior = this.behaviors.get(type);
if (!behavior) {
throw new Error(`Unregistered IrNode type: ${type}`);
}
return behavior;
}
}
@@ -0,0 +1,175 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Part } from '@google/genai';
import type {
IrNodeBehavior,
IrNodeBehaviorRegistry,
} from './behaviorRegistry.js';
import type {
UserPrompt,
AgentThought,
ToolExecution,
MaskedTool,
AgentYield,
Snapshot,
RollingSummary,
SystemEvent,
} from './types.js';
export const UserPromptBehavior: IrNodeBehavior<UserPrompt> = {
type: 'USER_PROMPT',
getEstimatableParts(prompt) {
const parts: Part[] = [];
for (const sp of prompt.semanticParts) {
switch (sp.type) {
case 'text':
parts.push({ text: sp.text });
break;
case 'inline_data':
parts.push({ inlineData: { mimeType: sp.mimeType, data: sp.data } });
break;
case 'file_data':
parts.push({
fileData: { mimeType: sp.mimeType, fileUri: sp.fileUri },
});
break;
case 'raw_part':
parts.push(sp.part);
break;
default:
break;
}
}
return parts;
},
serialize(prompt, writer) {
const parts = this.getEstimatableParts(prompt);
if (parts.length > 0) {
writer.flushModelParts();
writer.appendContent({ role: 'user', parts });
}
},
};
export const AgentThoughtBehavior: IrNodeBehavior<AgentThought> = {
type: 'AGENT_THOUGHT',
getEstimatableParts(thought) {
return [{ text: thought.text }];
},
serialize(thought, writer) {
writer.appendModelPart({ text: thought.text });
},
};
export const ToolExecutionBehavior: IrNodeBehavior<ToolExecution> = {
type: 'TOOL_EXECUTION',
getEstimatableParts(tool) {
return [
{ functionCall: { id: tool.id, name: tool.toolName, args: tool.intent } },
{
functionResponse: {
id: tool.id,
name: tool.toolName,
response:
typeof tool.observation === 'string'
? { message: tool.observation }
: tool.observation,
},
},
];
},
serialize(tool, writer) {
const parts = this.getEstimatableParts(tool);
writer.appendModelPart(parts[0]);
writer.flushModelParts();
writer.appendUserPart(parts[1]);
},
};
export const MaskedToolBehavior: IrNodeBehavior<MaskedTool> = {
type: 'MASKED_TOOL',
getEstimatableParts(tool) {
return [
{
functionCall: {
id: tool.id,
name: tool.toolName,
args: tool.intent ?? {},
},
},
{
functionResponse: {
id: tool.id,
name: tool.toolName,
response:
typeof tool.observation === 'string'
? { message: tool.observation }
: (tool.observation ?? {}),
},
},
];
},
serialize(tool, writer) {
const parts = this.getEstimatableParts(tool);
writer.appendModelPart(parts[0]);
writer.flushModelParts();
writer.appendUserPart(parts[1]);
},
};
export const AgentYieldBehavior: IrNodeBehavior<AgentYield> = {
type: 'AGENT_YIELD',
getEstimatableParts(yieldNode) {
return [{ text: yieldNode.text }];
},
serialize(yieldNode, writer) {
writer.appendModelPart({ text: yieldNode.text });
writer.flushModelParts();
},
};
export const SystemEventBehavior: IrNodeBehavior<SystemEvent> = {
type: 'SYSTEM_EVENT',
getEstimatableParts() {
return [];
},
serialize(node, writer) {
writer.flushModelParts();
},
};
export const SnapshotBehavior: IrNodeBehavior<Snapshot> = {
type: 'SNAPSHOT',
getEstimatableParts(node) {
return [{ text: node.text }];
},
serialize(node, writer) {
writer.flushModelParts();
writer.appendUserPart({ text: node.text });
},
};
export const RollingSummaryBehavior: IrNodeBehavior<RollingSummary> = {
type: 'ROLLING_SUMMARY',
getEstimatableParts(node) {
return [{ text: node.text }];
},
serialize(node, writer) {
writer.flushModelParts();
writer.appendUserPart({ text: node.text });
},
};
export function registerBuiltInBehaviors(registry: IrNodeBehaviorRegistry) {
registry.register(UserPromptBehavior);
registry.register(AgentThoughtBehavior);
registry.register(ToolExecutionBehavior);
registry.register(MaskedToolBehavior);
registry.register(AgentYieldBehavior);
registry.register(SystemEventBehavior);
registry.register(SnapshotBehavior);
registry.register(RollingSummaryBehavior);
}
+54
View File
@@ -0,0 +1,54 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content, Part } from '@google/genai';
import type { ConcreteNode } from './types.js';
import type {
IrSerializationWriter,
IrNodeBehaviorRegistry,
} from './behaviorRegistry.js';
class IrSerializer implements IrSerializationWriter {
private history: Content[] = [];
private currentModelParts: Part[] = [];
appendContent(content: Content) {
this.flushModelParts();
this.history.push(content);
}
appendModelPart(part: Part) {
this.currentModelParts.push(part);
}
appendUserPart(part: Part) {
this.flushModelParts();
this.history.push({ role: 'user', parts: [part] });
}
flushModelParts() {
if (this.currentModelParts.length > 0) {
this.history.push({ role: 'model', parts: [...this.currentModelParts] });
this.currentModelParts = [];
}
}
getContents(): Content[] {
this.flushModelParts();
return this.history;
}
}
export function fromIr(
nodes: readonly ConcreteNode[],
registry: IrNodeBehaviorRegistry,
): Content[] {
const writer = new IrSerializer();
for (const node of nodes) {
const behavior = registry.get(node.type);
behavior.serialize(node, writer);
}
return writer.getContents();
}
@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Episode,
Task,
IrNode,
AgentThought,
ToolExecution,
MaskedTool,
UserPrompt,
AgentYield,
SystemEvent,
Snapshot,
RollingSummary,
} from './types.js';
export function isEpisode(node: IrNode): node is Episode {
return node.type === 'EPISODE';
}
export function isTask(node: IrNode): node is Task {
return node.type === 'TASK';
}
export function isAgentThought(node: IrNode): node is AgentThought {
return node.type === 'AGENT_THOUGHT';
}
export function isAgentYield(node: IrNode): node is AgentYield {
return node.type === 'AGENT_YIELD';
}
export function isToolExecution(node: IrNode): node is ToolExecution {
return node.type === 'TOOL_EXECUTION';
}
export function isMaskedTool(node: IrNode): node is MaskedTool {
return node.type === 'MASKED_TOOL';
}
export function isUserPrompt(node: IrNode): node is UserPrompt {
return node.type === 'USER_PROMPT';
}
export function isSystemEvent(node: IrNode): node is SystemEvent {
return node.type === 'SYSTEM_EVENT';
}
export function isSnapshot(node: IrNode): node is Snapshot {
return node.type === 'SNAPSHOT';
}
export function isRollingSummary(node: IrNode): node is RollingSummary {
return node.type === 'ROLLING_SUMMARY';
}
+28
View File
@@ -0,0 +1,28 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content } from '@google/genai';
import type { Episode, ConcreteNode } from './types.js';
import { toIr } from './toIr.js';
import { fromIr } from './fromIr.js';
import type { ContextTokenCalculator } from '../utils/contextTokenCalculator.js';
import type { IrNodeBehaviorRegistry } from './behaviorRegistry.js';
export class IrMapper {
private readonly nodeIdentityMap = new WeakMap<object, string>();
constructor(private readonly registry: IrNodeBehaviorRegistry) {}
toIr(
history: readonly Content[],
tokenCalculator: ContextTokenCalculator,
): Episode[] {
return toIr(history, tokenCalculator, this.nodeIdentityMap);
}
fromIr(nodes: readonly ConcreteNode[]): Content[] {
return fromIr(nodes, this.registry);
}
}
+120
View File
@@ -0,0 +1,120 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content } from '@google/genai';
import type { ConcreteNode } from './types.js';
import { debugLogger } from '../../utils/debugLogger.js';
import type {
ContextEnvironment,
ContextTracer,
} from '../sidecar/environment.js';
import type { PipelineOrchestrator } from '../sidecar/orchestrator.js';
import type { SidecarConfig } from '../sidecar/types.js';
export class IrProjector {
/**
* Orchestrates the final projection: takes a working buffer view (The Nodes),
* applies the Immediate Sanitization pipeline, and enforces token boundaries.
*/
static async project(
nodes: readonly ConcreteNode[],
orchestrator: PipelineOrchestrator,
sidecar: SidecarConfig,
tracer: ContextTracer,
env: ContextEnvironment,
protectedIds: Set<string>,
): Promise<Content[]> {
if (!sidecar.budget) {
const contents = env.irMapper.fromIr(nodes);
tracer.logEvent('IrProjector', 'Projected Context to LLM (No Budget)', {
projectedContext: contents,
});
return contents;
}
const maxTokens = sidecar.budget.maxTokens;
const currentTokens =
env.tokenCalculator.calculateConcreteListTokens(nodes);
// V0: Always protect the first node (System Prompt) and the last turn
if (nodes.length > 0) {
protectedIds.add(nodes[0].id);
if (nodes[0].logicalParentId) protectedIds.add(nodes[0].logicalParentId);
const lastNode = nodes[nodes.length - 1];
protectedIds.add(lastNode.id);
if (lastNode.logicalParentId) protectedIds.add(lastNode.logicalParentId);
}
if (currentTokens <= maxTokens) {
tracer.logEvent(
'IrProjector',
`View is within maxTokens (${currentTokens} <= ${maxTokens}). Returning view.`,
);
const contents = env.irMapper.fromIr(nodes);
tracer.logEvent('IrProjector', 'Projected Context to LLM', {
projectedContext: contents,
});
return contents;
}
tracer.logEvent(
'IrProjector',
`View exceeds maxTokens (${currentTokens} > ${maxTokens}). Hitting Synchronous Pressure Barrier.`,
);
debugLogger.log(
`Context Manager Synchronous Barrier triggered: View at ${currentTokens} tokens (limit: ${maxTokens}).`,
);
// Calculate exactly which nodes aged out of the retainedTokens budget to form our target delta
const agedOutNodes = new Set<string>();
let rollingTokens = 0;
// Start from newest and count backwards
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i];
const nodeTokens = env.tokenCalculator.calculateConcreteListTokens([
node,
]);
rollingTokens += nodeTokens;
if (rollingTokens > sidecar.budget.retainedTokens) {
agedOutNodes.add(node.id);
}
}
const processedNodes = await orchestrator.executeTriggerSync(
'gc_backstop',
nodes,
agedOutNodes,
protectedIds,
);
const finalTokens =
env.tokenCalculator.calculateConcreteListTokens(processedNodes);
tracer.logEvent(
'IrProjector',
`Finished projection. Final token count: ${finalTokens}.`,
);
debugLogger.log(
`Context Manager finished. Final actual token count: ${finalTokens}.`,
);
// Apply skipList logic to abstract over summarized nodes
const skipList = new Set<string>();
for (const node of processedNodes) {
if (node.abstractsIds) {
for (const id of node.abstractsIds) skipList.add(id);
}
}
const visibleNodes = processedNodes.filter((n) => !skipList.has(n.id));
const contents = env.irMapper.fromIr(visibleNodes);
tracer.logEvent('IrProjector', 'Projected Sanitized Context to LLM', {
projectedContextSanitized: contents,
});
return contents;
}
}
+236
View File
@@ -0,0 +1,236 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content, Part } from '@google/genai';
import { randomUUID } from 'node:crypto';
import type {
Episode,
SemanticPart,
ToolExecution,
AgentThought,
AgentYield,
UserPrompt,
} from './types.js';
import type { ContextTokenCalculator } from '../utils/contextTokenCalculator.js';
// We remove the global nodeIdentityMap and instead rely on one passed from IrMapper
export function getStableId(
obj: object,
nodeIdentityMap: WeakMap<object, string>,
): string {
let id = nodeIdentityMap.get(obj);
if (!id) {
id = randomUUID();
nodeIdentityMap.set(obj, id);
}
return id;
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function isCompleteEpisode(ep: Partial<Episode>): ep is Episode {
return (
typeof ep.id === 'string' &&
typeof ep.timestamp === 'number' &&
Array.isArray(ep.concreteNodes) &&
ep.concreteNodes.length > 0
);
}
export function toIr(
history: readonly Content[],
tokenCalculator: ContextTokenCalculator,
nodeIdentityMap: WeakMap<object, string>,
): Episode[] {
const episodes: Episode[] = [];
let currentEpisode: Partial<Episode> | null = null;
const pendingCallParts: Map<string, Part> = new Map();
const finalizeEpisode = () => {
if (currentEpisode && isCompleteEpisode(currentEpisode)) {
episodes.push(currentEpisode);
}
currentEpisode = null;
};
for (const msg of history) {
if (!msg.parts) continue;
if (msg.role === 'user') {
const hasToolResponses = msg.parts.some((p) => !!p.functionResponse);
const hasUserParts = msg.parts.some(
(p) => !!p.text || !!p.inlineData || !!p.fileData,
);
if (hasToolResponses) {
currentEpisode = parseToolResponses(
msg,
currentEpisode,
pendingCallParts,
tokenCalculator,
nodeIdentityMap,
);
}
if (hasUserParts) {
finalizeEpisode();
currentEpisode = parseUserParts(msg, nodeIdentityMap);
}
} else if (msg.role === 'model') {
currentEpisode = parseModelParts(
msg,
currentEpisode,
pendingCallParts,
nodeIdentityMap,
);
}
}
if (currentEpisode) {
finalizeYield(currentEpisode);
finalizeEpisode();
}
return episodes;
}
function parseToolResponses(
msg: Content,
currentEpisode: Partial<Episode> | null,
pendingCallParts: Map<string, Part>,
tokenCalculator: ContextTokenCalculator,
nodeIdentityMap: WeakMap<object, string>,
): Partial<Episode> {
if (!currentEpisode) {
currentEpisode = {
id: getStableId(msg, nodeIdentityMap),
timestamp: Date.now(),
concreteNodes: [],
};
}
const parts = msg.parts || [];
for (const part of parts) {
if (part.functionResponse) {
const callId = part.functionResponse.id || '';
const matchingCall = pendingCallParts.get(callId);
const intentTokens = matchingCall
? tokenCalculator.estimateTokensForParts([matchingCall])
: 0;
const obsTokens = tokenCalculator.estimateTokensForParts([part]);
const step: ToolExecution = {
id: getStableId(part, nodeIdentityMap),
type: 'TOOL_EXECUTION',
toolName: part.functionResponse.name || 'unknown',
intent: isRecord(matchingCall?.functionCall?.args)
? matchingCall.functionCall.args
: {},
observation: isRecord(part.functionResponse.response)
? part.functionResponse.response
: {},
tokens: {
intent: intentTokens,
observation: obsTokens,
},
};
currentEpisode.concreteNodes = [
...(currentEpisode.concreteNodes || []),
step,
];
if (callId) pendingCallParts.delete(callId);
}
}
return currentEpisode;
}
function parseUserParts(
msg: Content,
nodeIdentityMap: WeakMap<object, string>,
): Partial<Episode> {
const semanticParts: SemanticPart[] = [];
const parts = msg.parts || [];
for (const p of parts) {
if (p.text !== undefined)
semanticParts.push({ type: 'text', text: p.text });
else if (p.inlineData)
semanticParts.push({
type: 'inline_data',
mimeType: p.inlineData.mimeType || '',
data: p.inlineData.data || '',
});
else if (p.fileData)
semanticParts.push({
type: 'file_data',
mimeType: p.fileData.mimeType || '',
fileUri: p.fileData.fileUri || '',
});
else if (!p.functionResponse)
semanticParts.push({ type: 'raw_part', part: p }); // Preserve unknowns
}
const baseObj = parts.length > 0 ? parts[0] : msg;
const trigger: UserPrompt = {
id: getStableId(baseObj, nodeIdentityMap),
type: 'USER_PROMPT',
semanticParts,
};
return {
id: getStableId(msg, nodeIdentityMap),
timestamp: Date.now(),
concreteNodes: [trigger],
};
}
function parseModelParts(
msg: Content,
currentEpisode: Partial<Episode> | null,
pendingCallParts: Map<string, Part>,
nodeIdentityMap: WeakMap<object, string>,
): Partial<Episode> {
if (!currentEpisode) {
currentEpisode = {
id: getStableId(msg, nodeIdentityMap),
timestamp: Date.now(),
concreteNodes: [],
};
}
const parts = msg.parts || [];
for (const part of parts) {
if (part.functionCall) {
const callId = part.functionCall.id || '';
if (callId) pendingCallParts.set(callId, part);
} else if (part.text) {
const thought: AgentThought = {
id: getStableId(part, nodeIdentityMap),
type: 'AGENT_THOUGHT',
text: part.text,
};
currentEpisode.concreteNodes = [
...(currentEpisode.concreteNodes || []),
thought,
];
}
}
return currentEpisode;
}
function finalizeYield(currentEpisode: Partial<Episode>) {
if (currentEpisode.concreteNodes && currentEpisode.concreteNodes.length > 0) {
const yieldNode: AgentYield = {
id: randomUUID(),
type: 'AGENT_YIELD',
text: 'Yield', // Synthesized yield since we don't have the original concrete node
};
const existingNodes = currentEpisode.concreteNodes || [];
currentEpisode.concreteNodes = [...existingNodes, yieldNode];
}
}
+177
View File
@@ -0,0 +1,177 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Part } from '@google/genai';
export type IrNodeType =
// Organic Concrete Nodes
| 'USER_PROMPT'
| 'SYSTEM_EVENT'
| 'AGENT_THOUGHT'
| 'TOOL_EXECUTION'
| 'AGENT_YIELD'
// Synthetic Concrete Nodes
| 'SNAPSHOT'
| 'ROLLING_SUMMARY'
| 'MASKED_TOOL'
// Logical Nodes
| 'TASK'
| 'EPISODE';
/** Base interface for all nodes in the Episodic IR */
export interface IrNode {
readonly id: string;
readonly type: IrNodeType;
}
/**
* Concrete Nodes: The atomic, renderable pieces of data.
* These are the actual "planks" of the Nodes of Theseus.
*/
export interface BaseConcreteNode extends IrNode {
/** The ID of the Logical Node (e.g., Episode) that structurally owns this node */
readonly logicalParentId?: string;
/** If this node replaced a single node 1:1 (e.g., masking), this points to the original */
readonly replacesId?: string;
/** If this node is a synthetic summary of N nodes, this points to the original IDs */
readonly abstractsIds?: readonly string[];
}
/**
* Semantic Parts for User Prompts
*/
export type SemanticPart =
| {
readonly type: 'text';
readonly text: string;
}
| {
readonly type: 'inline_data';
readonly mimeType: string;
readonly data: string;
}
| {
readonly type: 'file_data';
readonly mimeType: string;
readonly fileUri: string;
}
| {
readonly type: 'raw_part';
readonly part: Part;
};
/**
* Trigger Nodes
* Events that wake the agent up and initiate an Episode.
*/
export interface UserPrompt extends BaseConcreteNode {
readonly type: 'USER_PROMPT';
readonly semanticParts: readonly SemanticPart[];
}
export interface SystemEvent extends BaseConcreteNode {
readonly type: 'SYSTEM_EVENT';
readonly name: string;
readonly payload: Record<string, unknown>;
}
export type EpisodeTrigger = UserPrompt | SystemEvent;
/**
* Step Nodes
* The internal autonomous actions taken by the agent during its loop.
*/
export interface AgentThought extends BaseConcreteNode {
readonly type: 'AGENT_THOUGHT';
readonly text: string;
}
export interface ToolExecution extends BaseConcreteNode {
readonly type: 'TOOL_EXECUTION';
readonly toolName: string;
readonly intent: Record<string, unknown>;
readonly observation: string | Record<string, unknown>;
readonly tokens: {
readonly intent: number;
readonly observation: number;
};
}
export interface MaskedTool extends BaseConcreteNode {
readonly type: 'MASKED_TOOL';
readonly toolName: string;
readonly intent?: Record<string, unknown>;
readonly observation?: string | Record<string, unknown>;
readonly tokens: {
readonly intent: number;
readonly observation: number;
};
}
export type EpisodeStep = AgentThought | ToolExecution | MaskedTool;
/**
* Resolution Node
* The final message where the agent yields control back to the user.
*/
export interface AgentYield extends BaseConcreteNode {
readonly type: 'AGENT_YIELD';
readonly text: string;
}
/**
* Synthetic Leaf Interfaces
* Processors that generate summaries emit explicit synthetic nodes.
*/
export interface Snapshot extends BaseConcreteNode {
readonly type: 'SNAPSHOT';
readonly timestamp: number;
readonly text: string;
}
export interface RollingSummary extends BaseConcreteNode {
readonly type: 'ROLLING_SUMMARY';
readonly timestamp: number;
readonly text: string;
}
export type SyntheticLeaf = Snapshot | RollingSummary;
export type ConcreteNode =
| UserPrompt
| SystemEvent
| AgentThought
| ToolExecution
| MaskedTool
| AgentYield
| Snapshot
| RollingSummary;
/**
* Logical Nodes
* These define hierarchy and grouping. They do not directly render to Gemini.
*/
export interface Episode extends IrNode {
readonly type: 'EPISODE';
readonly timestamp: number;
/** References to the Concrete Node IDs that conceptually belong to this Episode. */
concreteNodes: readonly ConcreteNode[];
}
export interface Task extends IrNode {
readonly type: 'TASK';
readonly timestamp: number;
readonly goal: string;
readonly status: 'active' | 'completed' | 'failed';
/** References to the Episode IDs that belong to this task */
readonly episodeIds: readonly string[];
}
export type LogicalNode = Task | Episode;
+64
View File
@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ConcreteNode } from './ir/types.js';
export interface InboxMessage<T = unknown> {
id: string;
topic: string;
payload: T;
timestamp: number;
}
export interface InboxSnapshot {
getMessages<T = unknown>(topic: string): ReadonlyArray<InboxMessage<T>>;
consume(messageId: string): void;
}
export interface GraphMutation {
readonly processorId: string;
readonly timestamp: number;
readonly removedIds: readonly string[];
readonly addedNodes: readonly ConcreteNode[];
}
export interface ContextWorkingBuffer {
readonly nodes: readonly ConcreteNode[];
getPristineNodes(id: string): readonly ConcreteNode[];
getLineage(id: string): readonly ConcreteNode[];
getAuditLog(): readonly GraphMutation[];
}
export interface ProcessArgs {
readonly buffer: ContextWorkingBuffer;
readonly targets: readonly ConcreteNode[];
readonly inbox: InboxSnapshot;
}
export interface ContextProcessor {
readonly id: string;
readonly name: string;
process(args: ProcessArgs): Promise<readonly ConcreteNode[]>;
}
export interface ContextWorker {
readonly id: string;
readonly name: string;
readonly triggers: {
onNodesAdded?: boolean;
onNodesAgedOut?: boolean;
onInboxTopics?: string[];
};
execute(args: {
targets: readonly ConcreteNode[];
inbox: InboxSnapshot;
}): Promise<void>;
}
export interface BackstopTargetOptions {
target?: 'incremental' | 'freeNTokens' | 'max';
freeTokensTarget?: number;
}
@@ -0,0 +1,101 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import { describe, it, expect } from 'vitest';
import { BlobDegradationProcessor } from './blobDegradationProcessor.js';
import {
createMockProcessArgs,
createMockEnvironment,
createDummyNode,
} from '../testing/contextTestUtils.js';
import type { UserPrompt, SemanticPart, ConcreteNode } from '../ir/types.js';
describe('BlobDegradationProcessor', () => {
it('should ignore text parts and only target inline_data and file_data', async () => {
const env = createMockEnvironment();
// charsPerToken = 1
// We want the degraded text to be cheaper than the original blob.
// Degraded text is ~100 chars ("...degraded to text...").
// So we make the blob data 200 chars.
const fakeData = 'A'.repeat(200);
const processor = BlobDegradationProcessor.create(env, {});
const parts: SemanticPart[] = [
{ type: 'text', text: 'Hello' },
{ type: 'inline_data', mimeType: 'image/png', data: fakeData },
{ type: 'text', text: 'World' },
];
const prompt = createDummyNode('ep1', 'USER_PROMPT', 100, {
semanticParts: parts,
}) as UserPrompt;
const targets = [prompt];
const result = await processor.process(createMockProcessArgs(targets));
expect(result.length).toBe(1);
const modifiedPrompt = result[0] as UserPrompt;
expect(modifiedPrompt.id).not.toBe(prompt.id);
expect(modifiedPrompt.semanticParts.length).toBe(3);
// Text parts should be untouched
expect(modifiedPrompt.semanticParts[0]).toEqual(parts[0]);
expect(modifiedPrompt.semanticParts[2]).toEqual(parts[2]);
// The inline_data part should be replaced with text
const degradedPart = modifiedPrompt.semanticParts[1];
expect(degradedPart.type).toBe('text');
assert(degradedPart.type === 'text');
expect(degradedPart.text).toContain(
'[Multi-Modal Blob (image/png, 0.00MB) degraded to text',
);
});
it('should degrade all blobs unconditionally', async () => {
const env = createMockEnvironment();
const processor = BlobDegradationProcessor.create(env, {});
// Tokens for fileData = 258.
// Degraded text = "[File Reference (video/mp4) degraded to text to preserve context window. Original URI: gs://test1]"
// Degraded text length ~100 characters.
// Since charsPerToken=1, degraded text = 100 tokens.
// Tokens saved = 258 - 100 = 158. This is > 0, so it WILL degrade it!
const prompt = createDummyNode('ep1', 'USER_PROMPT', 100, {
semanticParts: [
{ type: 'file_data', mimeType: 'video/mp4', fileUri: 'gs://test1' },
{ type: 'file_data', mimeType: 'video/mp4', fileUri: 'gs://test2' },
],
}) as UserPrompt;
const targets = [prompt];
const result = await processor.process(createMockProcessArgs(targets));
const modifiedPrompt = result[0] as UserPrompt;
expect(modifiedPrompt.semanticParts.length).toBe(2);
// Both parts should be degraded
expect(modifiedPrompt.semanticParts[0].type).toBe('text');
expect(modifiedPrompt.semanticParts[1].type).toBe('text');
});
it('should return exactly the targets array if targets are empty', async () => {
const env = createMockEnvironment();
const processor = BlobDegradationProcessor.create(env, {});
const targets: ConcreteNode[] = [];
const result = await processor.process(createMockProcessArgs(targets));
expect(result).toBe(targets);
});
});
@@ -0,0 +1,161 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ProcessArgs, ContextProcessor } from '../pipeline.js';
import type { ConcreteNode, UserPrompt } from '../ir/types.js';
import type { ContextEnvironment } from '../sidecar/environment.js';
import { sanitizeFilenamePart } from '../../utils/fileUtils.js';
export type BlobDegradationProcessorOptions = Record<string, never>;
export class BlobDegradationProcessor implements ContextProcessor {
static create(
env: ContextEnvironment,
_options: BlobDegradationProcessorOptions,
): BlobDegradationProcessor {
return new BlobDegradationProcessor(env);
}
readonly componentType = 'processor';
readonly id = 'BlobDegradationProcessor';
readonly name = 'BlobDegradationProcessor';
readonly options = {};
private env: ContextEnvironment;
constructor(env: ContextEnvironment) {
this.env = env;
}
async process({ targets }: ProcessArgs): Promise<readonly ConcreteNode[]> {
if (targets.length === 0) {
return targets;
}
let directoryCreated = false;
let blobOutputsDir = this.env.fileSystem.join(
this.env.projectTempDir,
'degraded-blobs',
);
const sessionId = this.env.sessionId;
if (sessionId) {
blobOutputsDir = this.env.fileSystem.join(
blobOutputsDir,
`session-${sanitizeFilenamePart(sessionId)}`,
);
}
const ensureDir = async () => {
if (!directoryCreated) {
await this.env.fileSystem.mkdir(blobOutputsDir, { recursive: true });
directoryCreated = true;
}
};
const returnedNodes: ConcreteNode[] = [];
// Forward scan, looking for bloated non-text parts to degrade
for (const node of targets) {
switch (node.type) {
case 'USER_PROMPT': {
let modified = false;
const newParts = [...node.semanticParts];
for (let j = 0; j < node.semanticParts.length; j++) {
const part = node.semanticParts[j];
if (part.type === 'text') continue;
let newText = '';
let tokensSaved = 0;
switch (part.type) {
case 'inline_data': {
await ensureDir();
const ext = part.mimeType.split('/')[1] || 'bin';
const fileName = `blob_${Date.now()}_${this.env.idGenerator.generateId()}.${ext}`;
const filePath = this.env.fileSystem.join(
blobOutputsDir,
fileName,
);
const buffer = Buffer.from(part.data, 'base64');
await this.env.fileSystem.writeFile(filePath, buffer);
const mb = (buffer.byteLength / 1024 / 1024).toFixed(2);
newText = `[Multi-Modal Blob (${part.mimeType}, ${mb}MB) degraded to text to preserve context window. Saved to: ${filePath}]`;
const oldTokens =
this.env.tokenCalculator.estimateTokensForParts([
{
inlineData: { mimeType: part.mimeType, data: part.data },
},
]);
const newTokens =
this.env.tokenCalculator.estimateTokensForParts([
{ text: newText },
]);
tokensSaved = oldTokens - newTokens;
break;
}
case 'file_data': {
newText = `[File Reference (${part.mimeType}) degraded to text to preserve context window. Original URI: ${part.fileUri}]`;
const oldTokens =
this.env.tokenCalculator.estimateTokensForParts([
{
fileData: {
mimeType: part.mimeType,
fileUri: part.fileUri,
},
},
]);
const newTokens =
this.env.tokenCalculator.estimateTokensForParts([
{ text: newText },
]);
tokensSaved = oldTokens - newTokens;
break;
}
case 'raw_part': {
newText = `[Unknown Part degraded to text to preserve context window.]`;
const oldTokens =
this.env.tokenCalculator.estimateTokensForParts([part.part]);
const newTokens =
this.env.tokenCalculator.estimateTokensForParts([
{ text: newText },
]);
tokensSaved = oldTokens - newTokens;
break;
}
default:
break;
}
if (newText && tokensSaved > 0) {
newParts[j] = { type: 'text', text: newText };
modified = true;
}
}
if (modified) {
const degradedNode: UserPrompt = {
...node,
id: this.env.idGenerator.generateId(),
semanticParts: newParts,
};
returnedNodes.push(degradedNode);
} else {
returnedNodes.push(node);
}
break;
}
default:
returnedNodes.push(node);
break;
}
}
return returnedNodes;
}
}
@@ -0,0 +1,84 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
ContextProcessor,
BackstopTargetOptions,
ProcessArgs,
} from '../pipeline.js';
import type { ConcreteNode } from '../ir/types.js';
import type { ContextEnvironment } from '../sidecar/environment.js';
export type HistoryTruncationProcessorOptions = BackstopTargetOptions;
export class HistoryTruncationProcessor implements ContextProcessor {
static create(
env: ContextEnvironment,
options: HistoryTruncationProcessorOptions,
): HistoryTruncationProcessor {
return new HistoryTruncationProcessor(env, options);
}
static readonly schema = {
type: 'object',
properties: {
target: {
type: 'string',
enum: ['incremental', 'freeNTokens', 'max'],
description: 'How much of the targeted history to truncate.',
},
freeTokensTarget: {
type: 'number',
description: 'The number of tokens to free if target is freeNTokens.',
},
},
};
readonly componentType = 'processor';
readonly id = 'HistoryTruncationProcessor';
readonly name = 'HistoryTruncationProcessor';
private readonly env: ContextEnvironment;
readonly options: HistoryTruncationProcessorOptions;
constructor(
env: ContextEnvironment,
options: HistoryTruncationProcessorOptions,
) {
this.env = env;
this.options = options;
}
async process({ targets }: ProcessArgs): Promise<readonly ConcreteNode[]> {
// Calculate how many tokens we need to remove based on the configured knob
let targetTokensToRemove = 0;
const strategy = this.options.target ?? 'max';
if (strategy === 'incremental') {
targetTokensToRemove = Infinity;
} else if (strategy === 'freeNTokens') {
targetTokensToRemove = this.options.freeTokensTarget ?? 0;
if (targetTokensToRemove <= 0) return targets;
} else if (strategy === 'max') {
// 'max' means we remove all targets without stopping early
targetTokensToRemove = Infinity;
}
let removedTokens = 0;
const keptNodes: ConcreteNode[] = [];
// The targets are sequentially ordered from oldest to newest.
// We want to delete the oldest targets first.
for (const node of targets) {
if (removedTokens >= targetTokensToRemove) {
keptNodes.push(node);
continue;
}
removedTokens += this.env.tokenCalculator.getTokenCost(node);
}
return keptNodes;
}
}
@@ -0,0 +1,140 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import { describe, it, expect } from 'vitest';
import { NodeDistillationProcessor } from './nodeDistillationProcessor.js';
import {
createMockProcessArgs,
createMockEnvironment,
createDummyNode,
createDummyToolNode,
createMockLlmClient,
} from '../testing/contextTestUtils.js';
import type { UserPrompt, AgentThought, ToolExecution } from '../ir/types.js';
describe('NodeDistillationProcessor', () => {
it('should trigger summarization via LLM for long text parts', async () => {
const mockLlmClient = createMockLlmClient(['Mocked Summary!']);
// Use charsPerToken=1 naturally.
const env = createMockEnvironment({
llmClient: mockLlmClient,
});
const processor = NodeDistillationProcessor.create(env, {
nodeThresholdTokens: 10,
});
const longText = 'A'.repeat(50); // 50 chars
const prompt = createDummyNode(
'ep1',
'USER_PROMPT',
50,
{
semanticParts: [{ type: 'text', text: longText }],
},
'prompt-id',
) as UserPrompt;
const thought = createDummyNode(
'ep1',
'AGENT_THOUGHT',
50,
{
text: longText,
},
'thought-id',
) as AgentThought;
const tool = createDummyToolNode(
'ep1',
5,
500,
{
observation: { result: 'A'.repeat(500) },
},
'tool-id',
);
const targets = [prompt, thought, tool];
const result = await processor.process(createMockProcessArgs(targets));
expect(result.length).toBe(3);
// 1. User Prompt
const compressedPrompt = result[0] as UserPrompt;
expect(compressedPrompt.id).not.toBe(prompt.id);
expect(compressedPrompt.semanticParts[0].type).toBe('text');
assert(compressedPrompt.semanticParts[0].type === 'text');
expect(compressedPrompt.semanticParts[0].text).toBe('Mocked Summary!');
// 2. Agent Thought
const compressedThought = result[1] as AgentThought;
expect(compressedThought.id).not.toBe(thought.id);
expect(compressedThought.text).toBe('Mocked Summary!');
// 3. Tool Execution
const compressedTool = result[2] as ToolExecution;
expect(compressedTool.id).not.toBe(tool.id);
expect(compressedTool.observation).toEqual({ summary: 'Mocked Summary!' });
expect(mockLlmClient.generateContent).toHaveBeenCalledTimes(3);
});
it('should ignore nodes that are below the threshold', async () => {
const mockLlmClient = createMockLlmClient(['S']); // length = 1
const env = createMockEnvironment({
llmClient: mockLlmClient,
});
const processor = NodeDistillationProcessor.create(env, {
nodeThresholdTokens: 100, // Very high threshold
});
const shortText = 'Short text'; // 10 chars
const prompt = createDummyNode(
'ep1',
'USER_PROMPT',
10,
{
semanticParts: [{ type: 'text', text: shortText }],
},
'prompt-id',
) as UserPrompt;
const thought = createDummyNode(
'ep1',
'AGENT_THOUGHT',
13,
{
text: 'Short thought',
},
'thought-id',
) as AgentThought;
const targets = [prompt, thought];
const result = await processor.process(createMockProcessArgs(targets));
expect(result.length).toBe(2);
// 1. User Prompt (NOT compressed)
const untouchedPrompt = result[0] as UserPrompt;
expect(untouchedPrompt.id).toBe(prompt.id);
// 2. Agent Thought (NOT compressed)
const untouchedThought = result[1] as AgentThought;
expect(untouchedThought.id).toBe(thought.id);
// LLM should not have been called
expect(mockLlmClient.generateContent).toHaveBeenCalledTimes(0);
});
});
@@ -0,0 +1,221 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ContextProcessor, ProcessArgs } from '../pipeline.js';
import type { ConcreteNode } from '../ir/types.js';
import type { ContextEnvironment } from '../sidecar/environment.js';
import { debugLogger } from '../../utils/debugLogger.js';
import { getResponseText } from '../../utils/partUtils.js';
import { LlmRole } from '../../telemetry/llmRole.js';
export interface NodeDistillationProcessorOptions {
nodeThresholdTokens: number;
}
export class NodeDistillationProcessor implements ContextProcessor {
static create(
env: ContextEnvironment,
options: NodeDistillationProcessorOptions,
): NodeDistillationProcessor {
return new NodeDistillationProcessor(env, options);
}
static readonly schema = {
type: 'object',
properties: {
nodeThresholdTokens: {
type: 'number',
description: 'The token threshold above which nodes are summarized.',
},
},
required: ['nodeThresholdTokens'],
};
readonly componentType = 'processor';
readonly id = 'NodeDistillationProcessor';
readonly name = 'NodeDistillationProcessor';
readonly options: NodeDistillationProcessorOptions;
private env: ContextEnvironment;
constructor(
env: ContextEnvironment,
options: NodeDistillationProcessorOptions,
) {
this.env = env;
this.options = options;
}
private async generateSummary(
text: string,
contextInfo: string,
): Promise<string> {
try {
const response = await this.env.llmClient.generateContent({
role: LlmRole.UTILITY_COMPRESSOR,
modelConfigKey: { model: 'summarizer-default' },
promptId: this.env.promptId,
abortSignal: new AbortController().signal,
contents: [
{
role: 'user',
parts: [{ text }],
},
],
systemInstruction: {
role: 'system',
parts: [
{
text: `You are an expert context compressor. Your job is to drastically shorten the following ${contextInfo} while preserving the absolute core semantic meaning, facts, and intent. Omit all conversational filler, pleasantries, or redundant information. Return ONLY the compressed summary.`,
},
],
},
});
return getResponseText(response) || text;
} catch (e) {
debugLogger.warn(
`NodeDistillationProcessor failed to summarize ${contextInfo}`,
e,
);
return text; // Fallback to original text on API failure
}
}
async process({ targets }: ProcessArgs): Promise<readonly ConcreteNode[]> {
const semanticConfig = this.options;
const limitTokens = semanticConfig.nodeThresholdTokens;
const thresholdChars = this.env.tokenCalculator.tokensToChars(limitTokens);
const returnedNodes: ConcreteNode[] = [];
// Scan the target working buffer and unconditionally apply the configured hyperparameter threshold
for (const node of targets) {
switch (node.type) {
case 'USER_PROMPT': {
let modified = false;
const newParts = [...node.semanticParts];
for (let j = 0; j < node.semanticParts.length; j++) {
const part = node.semanticParts[j];
if (part.type !== 'text') continue;
if (part.text.length > thresholdChars) {
const summary = await this.generateSummary(
part.text,
'User Prompt',
);
const newTokens = this.env.tokenCalculator.estimateTokensForParts(
[{ text: summary }],
);
const oldTokens = this.env.tokenCalculator.estimateTokensForParts(
[{ text: part.text }],
);
if (newTokens < oldTokens) {
newParts[j] = { type: 'text', text: summary };
modified = true;
}
}
}
if (modified) {
returnedNodes.push({
...node,
id: this.env.idGenerator.generateId(),
semanticParts: newParts,
});
} else {
returnedNodes.push(node);
}
break;
}
case 'AGENT_THOUGHT': {
if (node.text.length > thresholdChars) {
const summary = await this.generateSummary(
node.text,
'Agent Thought',
);
const newTokens = this.env.tokenCalculator.estimateTokensForParts([
{ text: summary },
]);
const oldTokens = this.env.tokenCalculator.getTokenCost(node);
if (newTokens < oldTokens) {
returnedNodes.push({
...node,
id: this.env.idGenerator.generateId(),
text: summary,
});
break;
}
}
returnedNodes.push(node);
break;
}
case 'TOOL_EXECUTION': {
const rawObs = node.observation;
let stringifiedObs = '';
if (typeof rawObs === 'string') {
stringifiedObs = rawObs;
} else {
try {
stringifiedObs = JSON.stringify(rawObs);
} catch {
stringifiedObs = String(rawObs);
}
}
if (stringifiedObs.length > thresholdChars) {
const summary = await this.generateSummary(
stringifiedObs,
node.toolName || 'unknown',
);
const newObsObject = { summary };
const newObsTokens =
this.env.tokenCalculator.estimateTokensForParts([
{
functionResponse: {
name: node.toolName || 'unknown',
response: newObsObject,
id: node.id,
},
},
]);
const oldObsTokens =
node.tokens?.observation ??
this.env.tokenCalculator.getTokenCost(node);
const intentTokens = node.tokens?.intent ?? 0;
if (newObsTokens < oldObsTokens) {
returnedNodes.push({
...node,
id: this.env.idGenerator.generateId(),
observation: newObsObject as Record<string, unknown>,
tokens: {
intent: intentTokens,
observation: newObsTokens,
},
});
break;
}
}
returnedNodes.push(node);
break;
}
default:
returnedNodes.push(node);
break;
}
}
return returnedNodes;
}
}
@@ -0,0 +1,128 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import { describe, it, expect } from 'vitest';
import { NodeTruncationProcessor } from './nodeTruncationProcessor.js';
import {
createMockProcessArgs,
createMockEnvironment,
createDummyNode,
} from '../testing/contextTestUtils.js';
import type { UserPrompt, AgentThought, AgentYield } from '../ir/types.js';
describe('NodeTruncationProcessor', () => {
it('should truncate nodes that exceed maxTokensPerNode', async () => {
// env.tokenCalculator uses charsPerToken=1 natively.
const env = createMockEnvironment();
const processor = NodeTruncationProcessor.create(env, {
maxTokensPerNode: 10, // 10 chars limit
});
const longText = 'A'.repeat(50); // 50 tokens
const prompt = createDummyNode(
'ep1',
'USER_PROMPT',
50,
{
semanticParts: [{ type: 'text', text: longText }],
},
'prompt-id',
) as UserPrompt;
const thought = createDummyNode(
'ep1',
'AGENT_THOUGHT',
50,
{
text: longText,
},
'thought-id',
) as AgentThought;
const yieldNode = createDummyNode(
'ep1',
'AGENT_YIELD',
50,
{
text: longText,
},
'yield-id',
) as AgentYield;
const targets = [prompt, thought, yieldNode];
const result = await processor.process(createMockProcessArgs(targets));
expect(result.length).toBe(3);
// 1. User Prompt
const squashedPrompt = result[0] as UserPrompt;
expect(squashedPrompt.id).not.toBe(prompt.id);
expect(squashedPrompt.semanticParts[0].type).toBe('text');
assert(squashedPrompt.semanticParts[0].type === 'text');
expect(squashedPrompt.semanticParts[0].text).toContain('[... OMITTED');
// 2. Agent Thought
const squashedThought = result[1] as AgentThought;
expect(squashedThought.id).not.toBe(thought.id);
expect(squashedThought.text).toContain('[... OMITTED');
// 3. Agent Yield
const squashedYield = result[2] as AgentYield;
expect(squashedYield.id).not.toBe(yieldNode.id);
expect(squashedYield.text).toContain('[... OMITTED');
});
it('should ignore nodes that are below maxTokensPerNode', async () => {
const env = createMockEnvironment();
const processor = NodeTruncationProcessor.create(env, {
maxTokensPerNode: 100, // 100 chars limit
});
const shortText = 'Short text'; // 10 chars
const prompt = createDummyNode(
'ep1',
'USER_PROMPT',
10,
{
semanticParts: [{ type: 'text', text: shortText }],
},
'prompt-id',
) as UserPrompt;
const thought = createDummyNode(
'ep1',
'AGENT_THOUGHT',
13,
{
text: 'Short thought', // 13 chars
},
'thought-id',
) as AgentThought;
const targets = [prompt, thought];
const result = await processor.process(createMockProcessArgs(targets));
expect(result.length).toBe(2);
// 1. User Prompt (untouched)
const squashedPrompt = result[0] as UserPrompt;
expect(squashedPrompt.id).toBe(prompt.id);
assert(squashedPrompt.semanticParts[0].type === 'text');
expect(squashedPrompt.semanticParts[0].text).not.toContain('[... OMITTED');
// 2. Agent Thought (untouched)
const untouchedThought = result[1] as AgentThought;
expect(untouchedThought.id).toBe(thought.id);
expect(untouchedThought.text).not.toContain('[... OMITTED');
});
});
@@ -0,0 +1,156 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ContextProcessor, ProcessArgs } from '../pipeline.js';
import type { ContextEnvironment } from '../sidecar/environment.js';
import { truncateProportionally } from '../truncation.js';
import type { ConcreteNode } from '../ir/types.js';
export interface NodeTruncationProcessorOptions {
maxTokensPerNode: number;
}
export class NodeTruncationProcessor implements ContextProcessor {
static create(
env: ContextEnvironment,
options: NodeTruncationProcessorOptions,
): NodeTruncationProcessor {
return new NodeTruncationProcessor(env, options);
}
static readonly schema = {
type: 'object',
properties: {
maxTokensPerNode: {
type: 'number',
description:
'The maximum tokens a node can have before being truncated.',
},
},
required: ['maxTokensPerNode'],
};
readonly componentType = 'processor';
readonly id = 'NodeTruncationProcessor';
readonly name = 'NodeTruncationProcessor';
readonly options: NodeTruncationProcessorOptions;
private env: ContextEnvironment;
constructor(
env: ContextEnvironment,
options: NodeTruncationProcessorOptions,
) {
this.env = env;
this.options = options;
}
private tryApplySquash(
text: string,
limitChars: number,
): {
text: string;
newTokens: number;
oldTokens: number;
tokensSaved: number;
} | null {
const originalLength = text.length;
if (originalLength <= limitChars) return null;
const newText = truncateProportionally(
text,
limitChars,
`\n\n[... OMITTED ${originalLength - limitChars} chars ...]\n\n`,
);
if (newText !== text) {
// Using accurate TokenCalculator instead of simple math
const newTokens =
this.env.tokenCalculator.estimateTokensForString(newText);
const oldTokens = this.env.tokenCalculator.estimateTokensForString(text);
const tokensSaved = oldTokens - newTokens;
if (tokensSaved > 0) {
return { text: newText, newTokens, oldTokens, tokensSaved };
}
}
return null;
}
async process({ targets }: ProcessArgs): Promise<readonly ConcreteNode[]> {
if (targets.length === 0) {
return targets;
}
const { maxTokensPerNode } = this.options;
const limitChars = this.env.tokenCalculator.tokensToChars(maxTokensPerNode);
const returnedNodes: ConcreteNode[] = [];
for (const node of targets) {
switch (node.type) {
case 'USER_PROMPT': {
let modified = false;
const newParts = [...node.semanticParts];
for (let j = 0; j < node.semanticParts.length; j++) {
const part = node.semanticParts[j];
if (part.type === 'text') {
const squashResult = this.tryApplySquash(part.text, limitChars);
if (squashResult) {
newParts[j] = { type: 'text', text: squashResult.text };
modified = true;
}
}
}
if (modified) {
returnedNodes.push({
...node,
id: this.env.idGenerator.generateId(),
semanticParts: newParts,
});
} else {
returnedNodes.push(node);
}
break;
}
case 'AGENT_THOUGHT': {
const squashResult = this.tryApplySquash(node.text, limitChars);
if (squashResult) {
returnedNodes.push({
...node,
id: this.env.idGenerator.generateId(),
text: squashResult.text,
});
} else {
returnedNodes.push(node);
}
break;
}
case 'AGENT_YIELD': {
const squashResult = this.tryApplySquash(node.text, limitChars);
if (squashResult) {
returnedNodes.push({
...node,
id: this.env.idGenerator.generateId(),
text: squashResult.text,
});
} else {
returnedNodes.push(node);
}
break;
}
default:
returnedNodes.push(node);
break;
}
}
return returnedNodes;
}
}
@@ -0,0 +1,86 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { RollingSummaryProcessor } from './rollingSummaryProcessor.js';
import {
createMockProcessArgs,
createMockEnvironment,
createDummyNode,
} from '../testing/contextTestUtils.js';
describe('RollingSummaryProcessor', () => {
it('should initialize with correct default options', () => {
const env = createMockEnvironment();
const processor = RollingSummaryProcessor.create(env, {
target: 'incremental',
});
expect(processor.id).toBe('RollingSummaryProcessor');
});
it('should summarize older nodes when the deficit exceeds the threshold', async () => {
// env.tokenCalculator uses charsPerToken=1 based on createMockEnvironment
const env = createMockEnvironment();
// We want to free exactly 100 tokens.
// We will supply nodes that cost 50 tokens each.
const processor = RollingSummaryProcessor.create(env, {
target: 'freeNTokens',
freeTokensTarget: 100,
});
const text50 = 'A'.repeat(50);
const targets = [
createDummyNode(
'ep1',
'USER_PROMPT',
50,
{ semanticParts: [{ type: 'text', text: text50 }] },
'id1',
),
createDummyNode('ep1', 'AGENT_THOUGHT', 50, { text: text50 }, 'id2'),
createDummyNode('ep1', 'AGENT_YIELD', 50, { text: text50 }, 'id3'),
];
const result = await processor.process(createMockProcessArgs(targets));
// 3 nodes at 50 cost each.
// The first node (id1) is the initial USER_PROMPT and is always skipped by RollingSummaryProcessor.
// Node id2 adds 50 deficit. Node id3 adds 50 deficit. Total = 100 deficit, which hits the target break point.
// Thus, id2 and id3 are summarized into a new ROLLING_SUMMARY node.
expect(result.length).toBe(2);
expect(result[0].type).toBe('USER_PROMPT');
expect(result[1].type).toBe('ROLLING_SUMMARY');
});
it('should preserve targets if deficit does not trigger summary', async () => {
const env = createMockEnvironment();
// We want to free 100 tokens, but our nodes will only cost 10 tokens each.
const processor = RollingSummaryProcessor.create(env, {
target: 'freeNTokens',
freeTokensTarget: 100,
});
const text10 = 'A'.repeat(10);
const targets = [
createDummyNode(
'ep1',
'USER_PROMPT',
10,
{ semanticParts: [{ type: 'text', text: text10 }] },
'id1',
),
createDummyNode('ep1', 'AGENT_THOUGHT', 10, { text: text10 }, 'id2'),
];
const result = await processor.process(createMockProcessArgs(targets));
// Deficit accumulator reaches 10. This is < 100 limit, and total summarizable nodes < 2 anyway.
expect(result.length).toBe(2);
expect(result[0].type).toBe('USER_PROMPT');
expect(result[1].type).toBe('AGENT_THOUGHT');
});
});
@@ -0,0 +1,125 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
ContextProcessor,
ProcessArgs,
BackstopTargetOptions,
} from '../pipeline.js';
import type { ContextEnvironment } from '../sidecar/environment.js';
import type { ConcreteNode, RollingSummary } from '../ir/types.js';
import { SnapshotGenerator } from '../utils/snapshotGenerator.js';
import { debugLogger } from '../../utils/debugLogger.js';
export interface RollingSummaryProcessorOptions extends BackstopTargetOptions {
systemInstruction?: string;
}
export class RollingSummaryProcessor implements ContextProcessor {
static readonly schema = {
type: 'object',
properties: {
target: { type: 'string', enum: ['incremental', 'freeNTokens', 'max'] },
freeTokensTarget: { type: 'number' },
systemInstruction: { type: 'string' },
},
};
static create(
env: ContextEnvironment,
options: RollingSummaryProcessorOptions,
): RollingSummaryProcessor {
return new RollingSummaryProcessor(env, options);
}
readonly componentType = 'processor';
readonly id = 'RollingSummaryProcessor';
readonly name = 'RollingSummaryProcessor';
readonly options: RollingSummaryProcessorOptions;
private readonly env: ContextEnvironment;
private readonly generator: SnapshotGenerator;
constructor(
env: ContextEnvironment,
options: RollingSummaryProcessorOptions,
) {
this.env = env;
this.options = options;
this.generator = new SnapshotGenerator(env);
}
async process({ targets }: ProcessArgs): Promise<readonly ConcreteNode[]> {
if (targets.length === 0) return targets;
const strategy = this.options.target ?? 'max';
let targetTokensToRemove = 0;
if (strategy === 'incremental') {
// A rolling summary should target a small chunk. For now, since state isn't passed,
// we'll default to a fixed threshold, like 10000 tokens, to avoid eating the whole history.
// Ideally, the orchestrator should pass `tokensToRemove` explicitly.
targetTokensToRemove = 10000;
} else if (strategy === 'freeNTokens') {
targetTokensToRemove = this.options.freeTokensTarget ?? Infinity;
} else if (strategy === 'max') {
targetTokensToRemove = Infinity;
}
if (targetTokensToRemove <= 0) return targets;
let deficitAccumulator = 0;
const nodesToSummarize: ConcreteNode[] = [];
// Scan oldest to newest to find the oldest block that exceeds the token requirement
for (const node of targets) {
if (node.id === targets[0].id && node.type === 'USER_PROMPT') {
// Keep system prompt if it's the very first node
continue;
}
nodesToSummarize.push(node);
deficitAccumulator += this.env.tokenCalculator.getTokenCost(node);
if (deficitAccumulator >= targetTokensToRemove) break;
}
if (nodesToSummarize.length < 2) return targets; // Not enough context to summarize
try {
// Synthesize the rolling summary synchronously
const snapshotText = await this.generator.synthesizeSnapshot(
nodesToSummarize,
this.options.systemInstruction,
);
const newId = this.env.idGenerator.generateId();
const summaryNode: RollingSummary = {
id: newId,
logicalParentId: newId,
type: 'ROLLING_SUMMARY',
timestamp: Date.now(),
text: snapshotText,
};
const consumedIds = nodesToSummarize.map((n) => n.id);
const returnedNodes = targets.filter((t) => !consumedIds.includes(t.id));
const firstRemovedIdx = targets.findIndex((t) =>
consumedIds.includes(t.id),
);
if (firstRemovedIdx !== -1) {
const idx = Math.max(0, firstRemovedIdx);
returnedNodes.splice(idx, 0, summaryNode);
} else {
returnedNodes.unshift(summaryNode);
}
return returnedNodes;
} catch (e) {
debugLogger.error('RollingSummaryProcessor failed sync backstop', e);
return targets;
}
}
}
@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { StateSnapshotProcessor } from './stateSnapshotProcessor.js';
import {
createMockEnvironment,
createDummyNode,
createMockProcessArgs,
} from '../testing/contextTestUtils.js';
import type { InboxSnapshotImpl } from '../sidecar/inbox.js';
describe('StateSnapshotProcessor', () => {
it('should ignore if budget is satisfied', async () => {
const env = createMockEnvironment();
const processor = StateSnapshotProcessor.create(env, {
target: 'incremental',
});
const targets = [createDummyNode('ep1', 'USER_PROMPT')];
const result = await processor.process(createMockProcessArgs(targets));
expect(result).toBe(targets); // Strict equality
});
it('should apply a valid snapshot from the Inbox (Fast Path)', async () => {
const env = createMockEnvironment();
const processor = StateSnapshotProcessor.create(env, {
target: 'incremental',
});
const nodeA = createDummyNode('ep1', 'USER_PROMPT', 50, {}, 'node-A');
const nodeB = createDummyNode('ep1', 'AGENT_THOUGHT', 60, {}, 'node-B');
const nodeC = createDummyNode('ep2', 'USER_PROMPT', 50, {}, 'node-C');
const targets = [nodeA, nodeB, nodeC];
// The background worker created a snapshot of A and B
const messages = [
{
id: 'msg-1',
topic: 'PROPOSED_SNAPSHOT',
timestamp: Date.now(),
payload: {
consumedIds: ['node-A', 'node-B'],
newText: '<compressed A and B>',
type: 'point-in-time',
},
},
];
const processArgs = createMockProcessArgs(targets, [], messages);
const result = await processor.process(processArgs);
// Should remove A and B, insert Snapshot, keep C
expect(result.length).toBe(2);
expect(result[0].type).toBe('SNAPSHOT');
expect(result[1].id).toBe('node-C');
// Should consume the message
expect(
(processArgs.inbox as InboxSnapshotImpl).getConsumedIds().has('msg-1'),
).toBe(true);
});
it('should reject a snapshot if the nodes were modified/deleted (Cache Invalidated)', async () => {
const env = createMockEnvironment();
const processor = StateSnapshotProcessor.create(env, {
target: 'incremental',
});
// Make deficit 0 so we don't fall through to the sync backstop and fail the test that way
// node-A is MISSING (user deleted it)
const nodeB = createDummyNode('ep1', 'AGENT_THOUGHT', 60, {}, 'node-B');
const targets = [nodeB];
const messages = [
{
id: 'msg-1',
topic: 'PROPOSED_SNAPSHOT',
timestamp: Date.now(),
payload: {
consumedIds: ['node-A', 'node-B'],
newText: '<compressed A and B>',
},
},
];
const processArgs = createMockProcessArgs(targets, [], messages);
const result = await processor.process(processArgs);
// Because deficit is 0, and Inbox was rejected, nothing should change
expect(result.length).toBe(1);
expect(result[0].id).toBe('node-B');
expect(
(processArgs.inbox as InboxSnapshotImpl).getConsumedIds().has('msg-1'),
).toBe(false);
});
it('should fall back to sync backstop if inbox is empty', async () => {
const env = createMockEnvironment();
const processor = StateSnapshotProcessor.create(env, { target: 'max' }); // Summarize all
const nodeA = createDummyNode('ep1', 'USER_PROMPT', 50, {}, 'node-A');
const nodeB = createDummyNode('ep1', 'AGENT_THOUGHT', 60, {}, 'node-B');
const nodeC = createDummyNode('ep2', 'USER_PROMPT', 50, {}, 'node-C');
const targets = [nodeA, nodeB, nodeC];
const result = await processor.process(createMockProcessArgs(targets));
// Should synthesize a new snapshot synchronously
expect(env.llmClient.generateContent).toHaveBeenCalled();
expect(result.length).toBe(2); // nodeA is skipped as "system prompt", snapshot + nodeA
expect(result[1].type).toBe('SNAPSHOT');
});
});
@@ -0,0 +1,187 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
ContextProcessor,
ProcessArgs,
BackstopTargetOptions,
} from '../pipeline.js';
import type { ContextEnvironment } from '../sidecar/environment.js';
import type { ConcreteNode, Snapshot } from '../ir/types.js';
import { SnapshotGenerator } from '../utils/snapshotGenerator.js';
import { debugLogger } from '../../utils/debugLogger.js';
export interface StateSnapshotProcessorOptions extends BackstopTargetOptions {
model?: string;
systemInstruction?: string;
}
export class StateSnapshotProcessor implements ContextProcessor {
static readonly schema = {
type: 'object',
properties: {
target: { type: 'string', enum: ['incremental', 'freeNTokens', 'max'] },
freeTokensTarget: { type: 'number' },
model: { type: 'string' },
systemInstruction: { type: 'string' },
},
};
static create(
env: ContextEnvironment,
options: StateSnapshotProcessorOptions,
): StateSnapshotProcessor {
return new StateSnapshotProcessor(env, options);
}
readonly componentType = 'processor';
readonly id = 'StateSnapshotProcessor';
readonly name = 'StateSnapshotProcessor';
readonly options: StateSnapshotProcessorOptions;
private readonly env: ContextEnvironment;
private readonly generator: SnapshotGenerator;
constructor(env: ContextEnvironment, options: StateSnapshotProcessorOptions) {
this.env = env;
this.options = options;
this.generator = new SnapshotGenerator(env);
}
// --- ContextProcessor Interface (Sync Backstop / Cache Application) ---
async process({
targets,
inbox,
}: ProcessArgs): Promise<readonly ConcreteNode[]> {
if (targets.length === 0) {
return targets;
}
// Determine what mode we are looking for: 'incremental' -> 'point-in-time', 'max' -> 'accumulate'
const strategy = this.options.target ?? 'max';
const expectedType =
strategy === 'incremental' ? 'point-in-time' : 'accumulate';
// 1. Check Inbox for a completed Snapshot (The Fast Path)
const proposedSnapshots = inbox.getMessages<{
newText: string;
consumedIds: string[];
type: string;
}>('PROPOSED_SNAPSHOT');
if (proposedSnapshots.length > 0) {
// Filter for the snapshot type that matches our processor mode
const matchingSnapshots = proposedSnapshots.filter(
(s) => s.payload.type === expectedType,
);
// Sort by newest timestamp first (we want the most accumulated snapshot)
const sorted = [...matchingSnapshots].sort(
(a, b) => b.timestamp - a.timestamp,
);
for (const proposed of sorted) {
const { consumedIds, newText } = proposed.payload;
// Verify all consumed IDs still exist sequentially in targets
const targetIds = new Set(targets.map((t) => t.id));
const isValid = consumedIds.every((id) => targetIds.has(id));
if (isValid) {
// If valid, apply it!
const newId = this.env.idGenerator.generateId();
const snapshotNode: Snapshot = {
id: newId,
logicalParentId: newId,
type: 'SNAPSHOT',
timestamp: Date.now(),
text: newText,
};
// Remove the consumed nodes and insert the snapshot at the earliest index
const returnedNodes = targets.filter(
(t) => !consumedIds.includes(t.id),
);
const firstRemovedIdx = targets.findIndex((t) =>
consumedIds.includes(t.id),
);
if (firstRemovedIdx !== -1) {
const idx = Math.max(0, firstRemovedIdx);
returnedNodes.splice(idx, 0, snapshotNode);
} else {
returnedNodes.unshift(snapshotNode);
}
inbox.consume(proposed.id);
return returnedNodes;
}
}
}
// 2. The Synchronous Backstop (The Slow Path)
let targetTokensToRemove = 0;
if (strategy === 'incremental') {
targetTokensToRemove = Infinity; // incremental implies removing as much as possible if no state is passed
} else if (strategy === 'freeNTokens') {
targetTokensToRemove = this.options.freeTokensTarget ?? Infinity;
} else if (strategy === 'max') {
targetTokensToRemove = Infinity;
}
let deficitAccumulator = 0;
const nodesToSummarize: ConcreteNode[] = [];
// Scan oldest to newest
for (const node of targets) {
if (node.id === targets[0].id && node.type === 'USER_PROMPT') {
// Keep system prompt if it's the very first node
// In a real system, system prompt is protected, but we double check
continue;
}
nodesToSummarize.push(node);
deficitAccumulator += this.env.tokenCalculator.getTokenCost(node);
if (deficitAccumulator >= targetTokensToRemove) break;
}
if (nodesToSummarize.length < 2) return targets; // Not enough context
try {
const snapshotText = await this.generator.synthesizeSnapshot(
nodesToSummarize,
this.options.systemInstruction,
);
const newId = this.env.idGenerator.generateId();
const snapshotNode: Snapshot = {
id: newId,
logicalParentId: newId,
type: 'SNAPSHOT',
timestamp: Date.now(),
text: snapshotText,
};
const consumedIds = nodesToSummarize.map((n) => n.id);
const returnedNodes = targets.filter((t) => !consumedIds.includes(t.id));
const firstRemovedIdx = targets.findIndex((t) =>
consumedIds.includes(t.id),
);
if (firstRemovedIdx !== -1) {
const idx = Math.max(0, firstRemovedIdx);
returnedNodes.splice(idx, 0, snapshotNode);
} else {
returnedNodes.unshift(snapshotNode);
}
return returnedNodes;
} catch (e) {
debugLogger.error('StateSnapshotProcessor failed sync backstop', e);
return targets;
}
}
}
@@ -0,0 +1,112 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { StateSnapshotWorker } from './stateSnapshotWorker.js';
import {
createMockEnvironment,
createDummyNode,
} from '../testing/contextTestUtils.js';
import { InboxSnapshotImpl } from '../sidecar/inbox.js';
describe('StateSnapshotWorker', () => {
it('should generate a snapshot and publish it to the inbox', async () => {
const env = createMockEnvironment();
// Spy on the publish method
const publishSpy = vi.spyOn(env.inbox, 'publish');
const worker = StateSnapshotWorker.create(env, { type: 'point-in-time' });
const nodeA = createDummyNode('ep1', 'USER_PROMPT', 50, {}, 'node-A');
const nodeB = createDummyNode('ep1', 'AGENT_THOUGHT', 60, {}, 'node-B');
const targets = [nodeA, nodeB];
const inbox = new InboxSnapshotImpl([]);
await worker.execute({ targets, inbox });
// Ensure generateContent was called
expect(env.llmClient.generateContent).toHaveBeenCalled();
// Verify it published to the inbox
expect(publishSpy).toHaveBeenCalledWith(
'PROPOSED_SNAPSHOT',
expect.objectContaining({
newText: 'Mock LLM summary response',
consumedIds: ['node-A', 'node-B'],
type: 'point-in-time',
}),
env.idGenerator,
);
});
it('should pull previous accumulate snapshot from inbox and append new targets', async () => {
const env = createMockEnvironment();
const publishSpy = vi.spyOn(env.inbox, 'publish');
const drainSpy = vi.spyOn(env.inbox, 'drainConsumed');
const worker = StateSnapshotWorker.create(env, { type: 'accumulate' });
const nodeC = createDummyNode('ep2', 'USER_PROMPT', 50, {}, 'node-C');
const targets = [nodeC];
// Simulate an existing accumulate draft in the inbox
const inbox = new InboxSnapshotImpl([
{
id: 'draft-1',
topic: 'PROPOSED_SNAPSHOT',
timestamp: Date.now() - 1000,
payload: {
consumedIds: ['node-A', 'node-B'],
newText: '<old snapshot>',
type: 'accumulate',
},
},
]);
await worker.execute({ targets, inbox });
// The old draft should be consumed
expect(inbox.getConsumedIds().has('draft-1')).toBe(true);
expect(drainSpy).toHaveBeenCalledWith(expect.any(Set));
// The new publish should contain ALL consumed IDs (old + new)
expect(publishSpy).toHaveBeenCalledWith(
'PROPOSED_SNAPSHOT',
expect.objectContaining({
newText: 'Mock LLM summary response',
consumedIds: ['node-A', 'node-B', 'node-C'], // Aggregated!
type: 'accumulate',
}),
env.idGenerator,
);
// Verify the LLM was called with the old snapshot prepended
expect(env.llmClient.generateContent).toHaveBeenCalledWith(
expect.objectContaining({
contents: expect.arrayContaining([
expect.objectContaining({
parts: expect.arrayContaining([
expect.objectContaining({
text: expect.stringContaining('<old snapshot>'),
}),
]),
}),
]),
}),
);
});
it('should ignore empty targets', async () => {
const env = createMockEnvironment();
const publishSpy = vi.spyOn(env.inbox, 'publish');
const worker = StateSnapshotWorker.create(env, { type: 'accumulate' });
await worker.execute({ targets: [], inbox: new InboxSnapshotImpl([]) });
expect(env.llmClient.generateContent).not.toHaveBeenCalled();
expect(publishSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,127 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ContextWorker, InboxSnapshot } from '../pipeline.js';
import type { ContextEnvironment } from '../sidecar/environment.js';
import type { ConcreteNode } from '../ir/types.js';
import { SnapshotGenerator } from '../utils/snapshotGenerator.js';
import { debugLogger } from '../../utils/debugLogger.js';
export interface StateSnapshotWorkerOptions {
type?: 'accumulate' | 'point-in-time';
systemInstruction?: string;
}
export class StateSnapshotWorker implements ContextWorker {
static readonly schema = {
type: 'object',
properties: {
type: { type: 'string', enum: ['accumulate', 'point-in-time'] },
systemInstruction: { type: 'string' },
},
};
static create(
env: ContextEnvironment,
options: StateSnapshotWorkerOptions,
): StateSnapshotWorker {
return new StateSnapshotWorker(env, options);
}
readonly componentType = 'worker';
readonly id = 'StateSnapshotWorker';
readonly name = 'StateSnapshotWorker';
readonly options: StateSnapshotWorkerOptions;
private readonly env: ContextEnvironment;
private readonly generator: SnapshotGenerator;
// Triggers when nodes exceed retained threshold (via retained_exceeded in Orchestrator)
readonly triggers = {
onNodesAgedOut: true,
};
constructor(env: ContextEnvironment, options: StateSnapshotWorkerOptions) {
this.env = env;
this.options = options;
this.generator = new SnapshotGenerator(env);
}
async execute({
targets,
inbox,
}: {
targets: readonly ConcreteNode[];
inbox: InboxSnapshot;
}): Promise<void> {
if (targets.length === 0) return;
try {
let nodesToSummarize = [...targets];
let previousConsumedIds: string[] = [];
const workerType = this.options.type ?? 'point-in-time';
if (workerType === 'accumulate') {
// Look for the most recent unconsumed accumulate snapshot in the inbox
const proposedSnapshots = inbox.getMessages<{
newText: string;
consumedIds: string[];
type: string;
}>('PROPOSED_SNAPSHOT');
const accumulateSnapshots = proposedSnapshots.filter(
(s) => s.payload.type === 'accumulate',
);
if (accumulateSnapshots.length > 0) {
// Sort to find the most recent
const latest = [...accumulateSnapshots].sort(
(a, b) => b.timestamp - a.timestamp,
)[0];
// Consume the old draft so the inbox doesn't fill up with stale drafts
inbox.consume(latest.id);
// And we must persist its consumption back to the live inbox immediately,
// because we are effectively "taking" it from the shelf to modify.
this.env.inbox.drainConsumed(new Set([latest.id]));
previousConsumedIds = latest.payload.consumedIds;
// Prepend a synthetic node representing the previous rolling state
const previousStateNode: ConcreteNode = {
id: this.env.idGenerator.generateId(),
logicalParentId: '',
type: 'SNAPSHOT',
timestamp: latest.timestamp,
text: latest.payload.newText,
};
nodesToSummarize = [previousStateNode, ...targets];
}
}
const snapshotText = await this.generator.synthesizeSnapshot(
nodesToSummarize,
this.options.systemInstruction,
);
const newConsumedIds = [
...previousConsumedIds,
...targets.map((t) => t.id),
];
// In V2, workers communicate their work to the inbox, and the processor picks it up.
this.env.inbox.publish(
'PROPOSED_SNAPSHOT',
{
newText: snapshotText,
consumedIds: newConsumedIds,
type: workerType,
},
this.env.idGenerator,
);
} catch (e) {
debugLogger.error('StateSnapshotWorker failed to generate snapshot', e);
}
}
}
@@ -0,0 +1,68 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { ToolMaskingProcessor } from './toolMaskingProcessor.js';
import {
createMockProcessArgs,
createMockEnvironment,
createDummyToolNode,
} from '../testing/contextTestUtils.js';
import type { ToolExecution } from '../ir/types.js';
describe('ToolMaskingProcessor', () => {
it('should write large strings to disk and replace them with a masked pointer', async () => {
const env = createMockEnvironment();
// env uses charsPerToken=1 natively.
// original string lengths > stringLengthThresholdTokens (which is 10) will be masked
const processor = ToolMaskingProcessor.create(env, {
stringLengthThresholdTokens: 10,
});
const longString = 'A'.repeat(500); // 500 chars
const toolStep = createDummyToolNode('ep1', 50, 500, {
observation: {
result: longString,
metadata: 'short', // 5 chars, will not be masked
},
});
const result = await processor.process(createMockProcessArgs([toolStep]));
expect(result.length).toBe(1);
const masked = result[0] as ToolExecution;
// It should have generated a new ID because it modified it
expect(masked.id).not.toBe(toolStep.id);
// It should have masked the observation
const obs = masked.observation as { result: string; metadata: string };
expect(obs.result).toContain('<tool_output_masked>');
expect(obs.metadata).toBe('short'); // Untouched
});
it('should skip unmaskable tools', async () => {
const env = createMockEnvironment();
const processor = ToolMaskingProcessor.create(env, {
stringLengthThresholdTokens: 10,
});
const toolStep = createDummyToolNode('ep1', 10, 10, {
toolName: 'activate_skill',
observation: {
result:
'this is a really long string that normally would get masked but wont because of the tool name',
},
});
const result = await processor.process(createMockProcessArgs([toolStep]));
// Returned the exact same object reference
expect(result[0]).toBe(toolStep);
});
});
@@ -0,0 +1,284 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ContextProcessor, ProcessArgs } from '../pipeline.js';
import type { ConcreteNode, ToolExecution } from '../ir/types.js';
import type { ContextEnvironment } from '../sidecar/environment.js';
import { sanitizeFilenamePart } from '../../utils/fileUtils.js';
import {
ACTIVATE_SKILL_TOOL_NAME,
MEMORY_TOOL_NAME,
ASK_USER_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
} from '../../tools/tool-names.js';
import type { Part } from '@google/genai';
const UNMASKABLE_TOOLS = new Set([
ACTIVATE_SKILL_TOOL_NAME,
MEMORY_TOOL_NAME,
ASK_USER_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
]);
export interface ToolMaskingProcessorOptions {
stringLengthThresholdTokens: number;
}
type MaskableValue =
| string
| number
| boolean
| null
| MaskableValue[]
| { [key: string]: MaskableValue };
function isMaskableValue(val: unknown): val is MaskableValue {
if (
val === null ||
typeof val === 'string' ||
typeof val === 'number' ||
typeof val === 'boolean'
) {
return true;
}
if (Array.isArray(val)) {
return val.every(isMaskableValue);
}
if (typeof val === 'object') {
return Object.values(val).every(isMaskableValue);
}
return false;
}
function isMaskableRecord(val: unknown): val is Record<string, MaskableValue> {
return (
typeof val === 'object' &&
val !== null &&
!Array.isArray(val) &&
isMaskableValue(val)
);
}
export class ToolMaskingProcessor implements ContextProcessor {
static create(
env: ContextEnvironment,
options: ToolMaskingProcessorOptions,
): ToolMaskingProcessor {
return new ToolMaskingProcessor(env, options);
}
static readonly schema = {
type: 'object',
properties: {
stringLengthThresholdTokens: {
type: 'number',
description:
'The token threshold above which tool intents/observations are masked.',
},
},
required: ['stringLengthThresholdTokens'],
};
readonly componentType = 'processor';
readonly id = 'ToolMaskingProcessor';
readonly name = 'ToolMaskingProcessor';
readonly options: ToolMaskingProcessorOptions;
private env: ContextEnvironment;
constructor(env: ContextEnvironment, options: ToolMaskingProcessorOptions) {
this.env = env;
this.options = options;
}
private isAlreadyMasked(text: string): boolean {
return text.includes('<tool_output_masked>');
}
async process({ targets }: ProcessArgs): Promise<readonly ConcreteNode[]> {
const maskingConfig = this.options;
if (!maskingConfig) return targets;
if (targets.length === 0) return targets;
const limitChars = this.env.tokenCalculator.tokensToChars(
maskingConfig.stringLengthThresholdTokens,
);
let toolOutputsDir = this.env.fileSystem.join(
this.env.projectTempDir,
'tool-outputs',
);
const sessionId = this.env.sessionId;
if (sessionId) {
toolOutputsDir = this.env.fileSystem.join(
toolOutputsDir,
`session-${sanitizeFilenamePart(sessionId)}`,
);
}
let directoryCreated = false;
const handleMasking = async (
content: string,
toolName: string,
callId: string,
nodeType: string,
): Promise<string> => {
if (!directoryCreated) {
await this.env.fileSystem.mkdir(toolOutputsDir, { recursive: true });
directoryCreated = true;
}
const fileName = `${sanitizeFilenamePart(toolName).toLowerCase()}_${sanitizeFilenamePart(callId).toLowerCase()}_${nodeType}_${this.env.idGenerator.generateId()}.txt`;
const filePath = this.env.fileSystem.join(toolOutputsDir, fileName);
await this.env.fileSystem.writeFile(filePath, content);
const fileSizeMB = (
Buffer.byteLength(content, 'utf8') /
1024 /
1024
).toFixed(2);
const totalLines = content.split('\n').length;
return `<tool_output_masked>\n[Tool ${nodeType} string (${fileSizeMB}MB, ${totalLines} lines) masked to preserve context window. Full string saved to: ${filePath}]\n</tool_output_masked>`;
};
const returnedNodes: ConcreteNode[] = [];
for (const node of targets) {
switch (node.type) {
case 'TOOL_EXECUTION': {
const toolName = node.toolName;
if (toolName && UNMASKABLE_TOOLS.has(toolName)) {
returnedNodes.push(node);
break;
}
const callId = node.id || Date.now().toString();
const maskAsync = async (
obj: MaskableValue,
nodeType: string,
): Promise<{ masked: MaskableValue; changed: boolean }> => {
if (typeof obj === 'string') {
if (obj.length > limitChars && !this.isAlreadyMasked(obj)) {
const newString = await handleMasking(
obj,
toolName || 'unknown',
callId,
nodeType,
);
return { masked: newString, changed: true };
}
return { masked: obj, changed: false };
}
if (Array.isArray(obj)) {
let changed = false;
const masked: MaskableValue[] = [];
for (const item of obj) {
const res = await maskAsync(item, nodeType);
if (res.changed) changed = true;
masked.push(res.masked);
}
return { masked, changed };
}
if (typeof obj === 'object' && obj !== null) {
let changed = false;
const masked: Record<string, MaskableValue> = {};
for (const [key, value] of Object.entries(obj)) {
const res = await maskAsync(value, nodeType);
if (res.changed) changed = true;
masked[key] = res.masked;
}
return { masked, changed };
}
return { masked: obj, changed: false };
};
const rawIntent = node.intent;
const rawObs = node.observation;
if (!isMaskableRecord(rawIntent) || !isMaskableValue(rawObs)) {
returnedNodes.push(node);
break;
}
const intentRes = await maskAsync(rawIntent, 'intent');
const obsRes = await maskAsync(rawObs, 'observation');
if (intentRes.changed || obsRes.changed) {
const maskedIntent = isMaskableRecord(intentRes.masked)
? (intentRes.masked as Record<string, unknown>)
: undefined;
// Handle observation explicitly as string vs object
const maskedObs =
typeof obsRes.masked === 'string'
? ({ message: obsRes.masked } as Record<string, unknown>)
: isMaskableRecord(obsRes.masked)
? (obsRes.masked as Record<string, unknown>)
: undefined;
const newIntentTokens =
this.env.tokenCalculator.estimateTokensForParts([
{
functionCall: {
name: toolName || 'unknown',
args: maskedIntent,
id: callId,
},
},
]);
let obsPart: Record<string, unknown> = {};
if (maskedObs) {
obsPart = {
functionResponse: {
name: toolName || 'unknown',
response: maskedObs,
id: callId,
},
};
}
const newObsTokens =
this.env.tokenCalculator.estimateTokensForParts([
obsPart as Part,
]);
const tokensSaved =
this.env.tokenCalculator.getTokenCost(node) -
(newIntentTokens + newObsTokens);
if (tokensSaved > 0) {
const maskedNode: ToolExecution = {
...node,
id: this.env.idGenerator.generateId(), // Modified, so generate new ID
intent: maskedIntent ?? node.intent,
observation: maskedObs ?? node.observation,
tokens: {
intent: newIntentTokens,
observation: newObsTokens,
},
};
returnedNodes.push(maskedNode);
} else {
returnedNodes.push(node);
}
} else {
returnedNodes.push(node);
}
break;
}
default:
returnedNodes.push(node);
break;
}
}
return returnedNodes;
}
}
@@ -0,0 +1,66 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { SidecarRegistry } from './registry.js';
import { registerBuiltInProcessors } from './builtins.js';
import { describe, it, expect, beforeEach } from 'vitest';
import { SidecarLoader } from './SidecarLoader.js';
import { defaultSidecarProfile } from './profiles.js';
import { InMemoryFileSystem } from '../system/InMemoryFileSystem.js';
import type { Config } from 'src/config/config.js';
describe('SidecarLoader (Fake FS)', () => {
let fileSystem: InMemoryFileSystem;
let registry: SidecarRegistry;
beforeEach(() => {
fileSystem = new InMemoryFileSystem();
registry = new SidecarRegistry();
registerBuiltInProcessors(registry);
});
const mockConfig = {
getExperimentalContextSidecarConfig: () => '/path/to/sidecar.json',
} as unknown as Config;
it('returns default profile if file does not exist', () => {
const result = SidecarLoader.fromConfig(mockConfig, registry, fileSystem);
expect(result).toBe(defaultSidecarProfile);
});
it('returns default profile if file exists but is 0 bytes', () => {
fileSystem.setFile('/path/to/sidecar.json', '');
const result = SidecarLoader.fromConfig(mockConfig, registry, fileSystem);
expect(result).toBe(defaultSidecarProfile);
});
it('throws an error if file is empty whitespace', () => {
fileSystem.setFile('/path/to/sidecar.json', ' \n ');
expect(() =>
SidecarLoader.fromConfig(mockConfig, registry, fileSystem),
).toThrow('is empty');
});
it('returns parsed config if file is valid', () => {
const validConfig = {
budget: { retainedTokens: 1000, maxTokens: 2000 },
pipelines: [],
};
fileSystem.setFile('/path/to/sidecar.json', JSON.stringify(validConfig));
const result = SidecarLoader.fromConfig(mockConfig, registry, fileSystem);
expect(result.budget.maxTokens).toBe(2000);
});
it('throws validation error if file is invalid', () => {
const invalidConfig = {
budget: { retainedTokens: 1000 }, // missing maxTokens
};
fileSystem.setFile('/path/to/sidecar.json', JSON.stringify(invalidConfig));
expect(() =>
SidecarLoader.fromConfig(mockConfig, registry, fileSystem),
).toThrow('Validation error:');
});
});
@@ -0,0 +1,87 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '../../config/config.js';
import type { SidecarConfig } from './types.js';
import { defaultSidecarProfile } from './profiles.js';
import { SchemaValidator } from '../../utils/schemaValidator.js';
import { getSidecarConfigSchema } from './schema.js';
import type { IFileSystem } from '../system/IFileSystem.js';
import { NodeFileSystem } from '../system/NodeFileSystem.js';
import type { SidecarRegistry } from './registry.js';
export class SidecarLoader {
/**
* Loads and validates a sidecar config from a specific file path.
* Throws an error if the file cannot be read, parsed, or fails schema validation.
*/
static loadFromFile(
sidecarPath: string,
registry: SidecarRegistry,
fileSystem: IFileSystem = new NodeFileSystem(),
): SidecarConfig {
const fileContent = fileSystem.readFileSync(sidecarPath, 'utf8');
if (!fileContent.trim()) {
throw new Error(`Sidecar configuration file at ${sidecarPath} is empty.`);
}
let parsed: unknown;
try {
parsed = JSON.parse(fileContent);
} catch (error) {
throw new Error(
`Failed to parse Sidecar configuration file at ${sidecarPath}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
const validationError = SchemaValidator.validate(
getSidecarConfigSchema(registry),
parsed,
);
if (validationError) {
throw new Error(
`Invalid sidecar configuration in ${sidecarPath}. Validation error: ${validationError}`,
);
}
// Schema has been validated.
const isSidecarConfig = (val: unknown): val is SidecarConfig => true;
if (isSidecarConfig(parsed)) {
return parsed;
}
throw new Error(
'Unreachable: schema validation passed but type predicate failed.',
);
}
/**
* Generates a Sidecar JSON graph from the experimental config file path or defaults.
* If a config file is present but invalid, this will THROW to prevent silent misconfiguration.
*/
static fromConfig(
config: Config,
registry: SidecarRegistry,
fileSystem: IFileSystem = new NodeFileSystem(),
): SidecarConfig {
const sidecarPath = config.getExperimentalContextSidecarConfig();
if (sidecarPath && fileSystem.existsSync(sidecarPath)) {
const size = fileSystem.statSyncSize(sidecarPath);
// If the file exists but is completely empty (0 bytes), it's safe to fallback.
if (size === 0) {
return defaultSidecarProfile;
}
// If the file has content, enforce strict validation and throw on failure.
return this.loadFromFile(sidecarPath, registry, fileSystem);
}
return defaultSidecarProfile;
}
}
@@ -0,0 +1,85 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { SidecarRegistry } from './registry.js';
import {
HistoryTruncationProcessor,
type HistoryTruncationProcessorOptions,
} from '../processors/historyTruncationProcessor.js';
import { BlobDegradationProcessor } from '../processors/blobDegradationProcessor.js';
import {
NodeTruncationProcessor,
type NodeTruncationProcessorOptions,
} from '../processors/nodeTruncationProcessor.js';
import {
NodeDistillationProcessor,
type NodeDistillationProcessorOptions,
} from '../processors/nodeDistillationProcessor.js';
import {
ToolMaskingProcessor,
type ToolMaskingProcessorOptions,
} from '../processors/toolMaskingProcessor.js';
import {
StateSnapshotProcessor,
type StateSnapshotProcessorOptions,
} from '../processors/stateSnapshotProcessor.js';
import {
StateSnapshotWorker,
type StateSnapshotWorkerOptions,
} from '../processors/stateSnapshotWorker.js';
import {
RollingSummaryProcessor,
type RollingSummaryProcessorOptions,
} from '../processors/rollingSummaryProcessor.js';
export function registerBuiltInProcessors(registry: SidecarRegistry) {
registry.registerProcessor<Record<string, never>>({
id: 'BlobDegradationProcessor',
schema: { type: 'object', properties: {} },
create: (env) => new BlobDegradationProcessor(env),
});
registry.registerProcessor<HistoryTruncationProcessorOptions>({
id: 'HistoryTruncationProcessor',
schema: HistoryTruncationProcessor.schema,
create: (env, options) => HistoryTruncationProcessor.create(env, options),
});
registry.registerProcessor<NodeTruncationProcessorOptions>({
id: 'NodeTruncationProcessor',
schema: NodeTruncationProcessor.schema,
create: (env, options) => NodeTruncationProcessor.create(env, options),
});
registry.registerProcessor<NodeDistillationProcessorOptions>({
id: 'NodeDistillationProcessor',
schema: NodeDistillationProcessor.schema,
create: (env, options) => NodeDistillationProcessor.create(env, options),
});
registry.registerProcessor<ToolMaskingProcessorOptions>({
id: 'ToolMaskingProcessor',
schema: ToolMaskingProcessor.schema,
create: (env, options) => ToolMaskingProcessor.create(env, options),
});
registry.registerProcessor<StateSnapshotProcessorOptions>({
id: 'StateSnapshotProcessor',
schema: StateSnapshotProcessor.schema,
create: (env, options) => StateSnapshotProcessor.create(env, options),
});
registry.registerWorker<StateSnapshotWorkerOptions>({
id: 'StateSnapshotWorker',
schema: StateSnapshotWorker.schema,
create: (env, options) => StateSnapshotWorker.create(env, options),
});
registry.registerProcessor<RollingSummaryProcessorOptions>({
id: 'RollingSummaryProcessor',
schema: RollingSummaryProcessor.schema,
create: (env, options) => RollingSummaryProcessor.create(env, options),
});
}
@@ -0,0 +1,162 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { ContextWorkingBufferImpl } from './contextWorkingBuffer.js';
import { createDummyNode } from '../testing/contextTestUtils.js';
describe('ContextWorkingBufferImpl', () => {
it('should initialize with a pristine graph correctly', () => {
const pristine1 = createDummyNode(
'ep1',
'USER_PROMPT',
10,
undefined,
'p1',
);
const pristine2 = createDummyNode(
'ep1',
'AGENT_THOUGHT',
10,
undefined,
'p2',
);
const buffer = ContextWorkingBufferImpl.initialize([pristine1, pristine2]);
expect(buffer.nodes).toHaveLength(2);
expect(buffer.getAuditLog()).toHaveLength(0);
// Pristine nodes should point to themselves
expect(buffer.getPristineNodes('p1')).toEqual([pristine1]);
expect(buffer.getPristineNodes('p2')).toEqual([pristine2]);
});
it('should track 1:1 replacements (e.g., masking) and append to audit log', () => {
const pristine1 = createDummyNode(
'ep1',
'USER_PROMPT',
10,
undefined,
'p1',
);
let buffer = ContextWorkingBufferImpl.initialize([pristine1]);
const maskedNode = createDummyNode(
'ep1',
'USER_PROMPT',
5,
undefined,
'm1',
);
// Simulate what a processor does
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(maskedNode as any).replacesId = 'p1';
buffer = buffer.applyProcessorResult(
'ToolMasking',
[pristine1],
[maskedNode],
);
expect(buffer.nodes).toHaveLength(1);
expect(buffer.nodes[0].id).toBe('m1');
const log = buffer.getAuditLog();
expect(log).toHaveLength(1);
expect(log[0].processorId).toBe('ToolMasking');
expect(log[0].removedIds).toEqual(['p1']);
expect(log[0].addedNodes[0].id).toBe('m1');
// Provenance lookup: the masked node should resolve back to the pristine root
expect(buffer.getPristineNodes('m1')).toEqual([pristine1]);
});
it('should track N:1 abstractions (e.g., rolling summaries)', () => {
const p1 = createDummyNode('ep1', 'USER_PROMPT', 10, undefined, 'p1');
const p2 = createDummyNode('ep1', 'AGENT_THOUGHT', 10, undefined, 'p2');
const p3 = createDummyNode('ep1', 'USER_PROMPT', 10, undefined, 'p3');
let buffer = ContextWorkingBufferImpl.initialize([p1, p2, p3]);
const summaryNode = createDummyNode(
'ep1',
'ROLLING_SUMMARY',
15,
undefined,
's1',
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(summaryNode as any).abstractsIds = ['p1', 'p2'];
buffer = buffer.applyProcessorResult('Summarizer', [p1, p2], [summaryNode]);
// p1 and p2 are removed, p3 remains, s1 is added
expect(buffer.nodes.map((n) => n.id)).toEqual(['p3', 's1']);
// Provenance lookup: The summary node should resolve to both p1 and p2!
const roots = buffer.getPristineNodes('s1');
expect(roots).toHaveLength(2);
expect(roots).toContain(p1);
expect(roots).toContain(p2);
});
it('should track multi-generation provenance correctly', () => {
const p1 = createDummyNode('ep1', 'USER_PROMPT', 10, undefined, 'p1');
let buffer = ContextWorkingBufferImpl.initialize([p1]);
// Gen 1: Masked
const gen1 = createDummyNode('ep1', 'USER_PROMPT', 8, undefined, 'gen1');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(gen1 as any).replacesId = 'p1';
buffer = buffer.applyProcessorResult('Masking', [p1], [gen1]);
// Gen 2: Summarized
const gen2 = createDummyNode(
'ep1',
'ROLLING_SUMMARY',
5,
undefined,
'gen2',
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(gen2 as any).abstractsIds = ['gen1'];
buffer = buffer.applyProcessorResult('Summarizer', [gen1], [gen2]);
expect(buffer.nodes).toHaveLength(1);
expect(buffer.nodes[0].id).toBe('gen2');
// Audit log should show sequence
const log = buffer.getAuditLog();
expect(log).toHaveLength(2);
expect(log[0].processorId).toBe('Masking');
expect(log[1].processorId).toBe('Summarizer');
// Multi-gen Provenance lookup: gen2 -> gen1 -> p1
expect(buffer.getPristineNodes('gen2')).toEqual([p1]);
});
it('should handle net-new injected nodes without throwing', () => {
const p1 = createDummyNode('ep1', 'USER_PROMPT', 10, undefined, 'p1');
let buffer = ContextWorkingBufferImpl.initialize([p1]);
const injected = createDummyNode(
'ep1',
'SYSTEM_EVENT',
5,
undefined,
'injected1',
);
// No replacesId or abstractsIds
buffer = buffer.applyProcessorResult('Injector', [], [injected]);
expect(buffer.nodes.map((n) => n.id)).toEqual(['p1', 'injected1']);
// It should root to itself
expect(buffer.getPristineNodes('injected1')).toEqual([injected]);
});
});
@@ -0,0 +1,195 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ContextWorkingBuffer, GraphMutation } from '../pipeline.js';
import type { ConcreteNode } from '../ir/types.js';
export class ContextWorkingBufferImpl implements ContextWorkingBuffer {
// The current active graph
readonly nodes: readonly ConcreteNode[];
// The AOT pre-calculated provenance index (Current ID -> Pristine IDs)
private readonly provenanceMap: ReadonlyMap<string, ReadonlySet<string>>;
// The original immutable pristine nodes mapping
private readonly pristineNodesMap: ReadonlyMap<string, ConcreteNode>;
// The historical linked list of changes
private readonly history: readonly GraphMutation[];
private constructor(
nodes: readonly ConcreteNode[],
pristineNodesMap: ReadonlyMap<string, ConcreteNode>,
provenanceMap: ReadonlyMap<string, ReadonlySet<string>>,
history: readonly GraphMutation[],
) {
this.nodes = nodes;
this.pristineNodesMap = pristineNodesMap;
this.provenanceMap = provenanceMap;
this.history = history;
}
/**
* Initializes a brand new ContextWorkingBuffer from a pristine graph.
* Every node's provenance points to itself.
*/
static initialize(
pristineNodes: readonly ConcreteNode[],
): ContextWorkingBufferImpl {
const pristineMap = new Map<string, ConcreteNode>();
const initialProvenance = new Map<string, ReadonlySet<string>>();
for (const node of pristineNodes) {
pristineMap.set(node.id, node);
initialProvenance.set(node.id, new Set([node.id]));
}
return new ContextWorkingBufferImpl(
pristineNodes,
pristineMap,
initialProvenance,
[], // Empty history
);
}
/**
* Appends newly observed pristine nodes (e.g. from a user message) to the working buffer.
* Ensures they are tracked in the pristine map and point to themselves in provenance.
*/
appendPristineNodes(
newNodes: readonly ConcreteNode[],
): ContextWorkingBufferImpl {
if (newNodes.length === 0) return this;
const newPristineMap = new Map<string, ConcreteNode>(this.pristineNodesMap);
const newProvenanceMap = new Map(this.provenanceMap);
for (const node of newNodes) {
newPristineMap.set(node.id, node);
newProvenanceMap.set(node.id, new Set([node.id]));
}
return new ContextWorkingBufferImpl(
[...this.nodes, ...newNodes],
newPristineMap,
newProvenanceMap,
[...this.history],
);
}
/**
* Generates an entirely new buffer instance by calculating the delta between the processor's input and output.
*/
applyProcessorResult(
processorId: string,
inputTargets: readonly ConcreteNode[],
outputNodes: readonly ConcreteNode[],
): ContextWorkingBufferImpl {
const outputIds = new Set(outputNodes.map((n) => n.id));
const inputIds = new Set(inputTargets.map((n) => n.id));
// Calculate diffs
const removedIds = inputTargets
.filter((n) => !outputIds.has(n.id))
.map((n) => n.id);
const addedNodes = outputNodes.filter((n) => !inputIds.has(n.id));
// Create mutation record
const mutation: GraphMutation = {
processorId,
timestamp: Date.now(),
removedIds,
addedNodes,
};
// Calculate new node array
const removedSet = new Set(removedIds);
const retainedNodes = this.nodes.filter((n) => !removedSet.has(n.id));
const newGraph = [...retainedNodes];
// We append the output nodes in the same general position if possible,
// but in a complex graph we just ensure they exist. V2 graph uses timestamps for order.
// For simplicity, we just push added nodes to the end of the retained array
newGraph.push(...addedNodes);
// Calculate new provenance map
const newProvenanceMap = new Map(this.provenanceMap);
let finalPristineMap = this.pristineNodesMap;
// Map the new synthetic nodes back to their pristine roots
for (const added of addedNodes) {
const roots = new Set<string>();
// 1:1 Replacement (e.g. Masked Node)
if (added.replacesId) {
const inheritedRoots = this.provenanceMap.get(added.replacesId);
if (inheritedRoots) {
for (const rootId of inheritedRoots) roots.add(rootId);
}
}
// N:1 Abstraction (e.g. Rolling Summary)
if (added.abstractsIds) {
for (const abstractId of added.abstractsIds) {
const inheritedRoots = this.provenanceMap.get(abstractId);
if (inheritedRoots) {
for (const rootId of inheritedRoots) roots.add(rootId);
}
}
}
// If it has no links back to the original graph, it is its own root
// (e.g., a system-injected instruction)
if (roots.size === 0) {
roots.add(added.id);
// It acts as a net-new pristine root.
if (!finalPristineMap.has(added.id)) {
const mutableMap = new Map<string, ConcreteNode>(finalPristineMap);
mutableMap.set(added.id, added);
finalPristineMap = mutableMap;
}
}
newProvenanceMap.set(added.id, roots);
}
return new ContextWorkingBufferImpl(
newGraph,
finalPristineMap,
newProvenanceMap,
[...this.history, mutation],
);
}
getPristineNodes(id: string): readonly ConcreteNode[] {
const pristineIds = this.provenanceMap.get(id);
if (!pristineIds) return [];
return Array.from(pristineIds).map(
(pid) => this.pristineNodesMap.get(pid)!,
);
}
getAuditLog(): readonly GraphMutation[] {
return this.history;
}
getLineage(id: string): readonly ConcreteNode[] {
const lineage: ConcreteNode[] = [];
const currentNodesMap = new Map(this.nodes.map((n) => [n.id, n]));
let current = currentNodesMap.get(id);
while (current) {
lineage.push(current);
if (current.logicalParentId && current.logicalParentId !== current.id) {
current = currentNodesMap.get(current.logicalParentId);
} else {
break;
}
}
return lineage;
}
}
@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
import type { ContextEventBus } from '../eventBus.js';
import type { ContextTokenCalculator } from '../utils/contextTokenCalculator.js';
import type { ContextTracer } from '../tracer.js';
import type { IFileSystem } from '../system/IFileSystem.js';
import type { IIdGenerator } from '../system/IIdGenerator.js';
import type { LiveInbox } from './inbox.js';
import type { IrNodeBehaviorRegistry } from '../ir/behaviorRegistry.js';
import type { IrMapper } from '../ir/mapper.js';
export type { ContextTracer, ContextEventBus };
export interface ContextEnvironment {
readonly llmClient: BaseLlmClient;
readonly promptId: string;
readonly sessionId: string;
readonly traceDir: string;
readonly projectTempDir: string;
readonly tracer: ContextTracer;
readonly charsPerToken: number;
readonly tokenCalculator: ContextTokenCalculator;
readonly fileSystem: IFileSystem;
readonly idGenerator: IIdGenerator;
readonly eventBus: ContextEventBus;
readonly inbox: LiveInbox;
readonly behaviorRegistry: IrNodeBehaviorRegistry;
readonly irMapper: IrMapper;
}
@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { ContextEnvironmentImpl } from './environmentImpl.js';
import { ContextTracer } from '../tracer.js';
import { ContextEventBus } from '../eventBus.js';
import { InMemoryFileSystem } from '../system/InMemoryFileSystem.js';
import { DeterministicIdGenerator } from '../system/DeterministicIdGenerator.js';
import { createMockLlmClient } from '../testing/contextTestUtils.js';
describe('ContextEnvironmentImpl', () => {
it('should initialize with defaults correctly', () => {
const tracer = new ContextTracer({ targetDir: '/tmp', sessionId: 'mock' });
const eventBus = new ContextEventBus();
const mockLlmClient = createMockLlmClient();
const env = new ContextEnvironmentImpl(
mockLlmClient,
'mock-session',
'mock-prompt',
'/tmp/trace',
'/tmp/temp',
tracer,
4,
eventBus,
);
expect(env.llmClient).toBe(mockLlmClient);
expect(env.sessionId).toBe('mock-session');
expect(env.promptId).toBe('mock-prompt');
expect(env.traceDir).toBe('/tmp/trace');
expect(env.projectTempDir).toBe('/tmp/temp');
expect(env.tracer).toBe(tracer);
expect(env.charsPerToken).toBe(4);
expect(env.eventBus).toBe(eventBus);
// Default internals
expect(env.behaviorRegistry).toBeDefined();
expect(env.tokenCalculator).toBeDefined();
expect(env.fileSystem).toBeDefined();
expect(env.idGenerator).toBeDefined();
expect(env.inbox).toBeDefined();
expect(env.irMapper).toBeDefined();
});
it('should initialize with provided overrides', () => {
const tracer = new ContextTracer({ targetDir: '/tmp', sessionId: 'mock' });
const eventBus = new ContextEventBus();
const mockLlmClient = createMockLlmClient();
const fileSystem = new InMemoryFileSystem();
const idGenerator = new DeterministicIdGenerator('test-');
const env = new ContextEnvironmentImpl(
mockLlmClient,
'mock-session',
'mock-prompt',
'/tmp/trace',
'/tmp/temp',
tracer,
4,
eventBus,
fileSystem,
idGenerator,
);
expect(env.fileSystem).toBe(fileSystem);
expect(env.idGenerator).toBe(idGenerator);
});
});
@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
import type { ContextTracer } from '../tracer.js';
import type { ContextEnvironment } from './environment.js';
import type { ContextEventBus } from '../eventBus.js';
import { ContextTokenCalculator } from '../utils/contextTokenCalculator.js';
import type { IFileSystem } from '../system/IFileSystem.js';
import { NodeFileSystem } from '../system/NodeFileSystem.js';
import type { IIdGenerator } from '../system/IIdGenerator.js';
import { NodeIdGenerator } from '../system/NodeIdGenerator.js';
import { LiveInbox } from './inbox.js';
import { IrNodeBehaviorRegistry } from '../ir/behaviorRegistry.js';
import { registerBuiltInBehaviors } from '../ir/builtinBehaviors.js';
import { IrMapper } from '../ir/mapper.js';
export class ContextEnvironmentImpl implements ContextEnvironment {
readonly tokenCalculator: ContextTokenCalculator;
readonly fileSystem: IFileSystem;
readonly idGenerator: IIdGenerator;
readonly inbox: LiveInbox;
readonly behaviorRegistry: IrNodeBehaviorRegistry;
readonly irMapper: IrMapper;
constructor(
readonly llmClient: BaseLlmClient,
readonly sessionId: string,
readonly promptId: string,
readonly traceDir: string,
readonly projectTempDir: string,
readonly tracer: ContextTracer,
readonly charsPerToken: number,
readonly eventBus: ContextEventBus,
fileSystem?: IFileSystem,
idGenerator?: IIdGenerator,
) {
this.behaviorRegistry = new IrNodeBehaviorRegistry();
registerBuiltInBehaviors(this.behaviorRegistry);
this.tokenCalculator = new ContextTokenCalculator(
this.charsPerToken,
this.behaviorRegistry,
);
this.fileSystem = fileSystem || new NodeFileSystem();
this.idGenerator = idGenerator || new NodeIdGenerator();
this.inbox = new LiveInbox();
this.irMapper = new IrMapper(this.behaviorRegistry);
}
}
@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { LiveInbox, InboxSnapshotImpl } from './inbox.js';
import { DeterministicIdGenerator } from '../system/DeterministicIdGenerator.js';
describe('Inbox', () => {
it('should publish messages and provide snapshots', () => {
const inbox = new LiveInbox();
const idGenerator = new DeterministicIdGenerator('mock-uuid-');
inbox.publish('test-topic', { data: 'hello' }, idGenerator);
inbox.publish('other-topic', { data: 'world' }, idGenerator);
const messages = inbox.getMessages();
expect(messages.length).toBe(2);
expect(messages[0].topic).toBe('test-topic');
expect(messages[0].payload).toEqual({ data: 'hello' });
});
it('should drain consumed messages from the snapshot', () => {
const inbox = new LiveInbox();
const idGenerator = new DeterministicIdGenerator('mock-uuid-');
inbox.publish('test-topic', { data: 'hello' }, idGenerator);
inbox.publish('other-topic', { data: 'world' }, idGenerator);
const messages = inbox.getMessages();
const snapshot = new InboxSnapshotImpl(messages);
const filtered = snapshot.getMessages<{ data: string }>('test-topic');
expect(filtered.length).toBe(1);
expect(filtered[0].payload.data).toBe('hello');
// Consume the message
snapshot.consume(filtered[0].id);
// Provide the consumed IDs to the real inbox to drain them
inbox.drainConsumed(snapshot.getConsumedIds());
const finalMessages = inbox.getMessages();
expect(finalMessages.length).toBe(1);
expect(finalMessages[0].topic).toBe('other-topic');
});
});
@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { InboxMessage, InboxSnapshot } from '../pipeline.js';
export class LiveInbox {
private messages: InboxMessage[] = [];
publish<T>(
topic: string,
payload: T,
idGenerator: { generateId(): string },
): void {
this.messages.push({
id: idGenerator.generateId(),
topic,
payload,
timestamp: Date.now(),
});
}
getMessages(): readonly InboxMessage[] {
return [...this.messages];
}
drainConsumed(consumedIds: Set<string>): void {
this.messages = this.messages.filter((m) => !consumedIds.has(m.id));
}
}
export class InboxSnapshotImpl implements InboxSnapshot {
private messages: readonly InboxMessage[];
private consumedIds = new Set<string>();
constructor(messages: readonly InboxMessage[]) {
this.messages = messages;
}
getMessages<T = unknown>(topic: string): ReadonlyArray<InboxMessage<T>> {
const raw = this.messages.filter((m) => m.topic === topic);
/*
* Architectural Justification for Unchecked Cast:
* The Inbox is a heterogeneous event bus designed to support arbitrary, declarative
* routing via configuration files (where topics are just strings). Because TypeScript
* completely erases generic type information (<T>) at runtime, the central array
* can only hold `unknown` payloads. To enforce strict type safety without a central
* registry (which would break decoupling) or heavy runtime validation (Zod schemas),
* we must assert the type boundary here. The contract relies on the Worker and Processor
* agreeing on the payload structure associated with the configured topic string.
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return raw as ReadonlyArray<InboxMessage<T>>;
}
consume(messageId: string): void {
this.consumedIds.add(messageId);
}
getConsumedIds(): Set<string> {
return this.consumedIds;
}
}
@@ -0,0 +1,316 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { PipelineOrchestrator } from './orchestrator.js';
import { SidecarRegistry } from './registry.js';
import {
createMockEnvironment,
createDummyNode,
} from '../testing/contextTestUtils.js';
import type { ContextEnvironment } from './environment.js';
import type {
ContextProcessor,
ContextWorker,
InboxSnapshot,
ProcessArgs,
} from '../pipeline.js';
import type { PipelineDef, ProcessorConfig, SidecarConfig } from './types.js';
import type { ContextEventBus } from '../eventBus.js';
import type { ConcreteNode, UserPrompt } from '../ir/types.js';
// A realistic mock processor that modifies the text of the first target node
class ModifyingProcessor implements ContextProcessor {
static create() {
return new ModifyingProcessor();
}
constructor() {}
readonly name = 'ModifyingProcessor';
readonly id = 'ModifyingProcessor';
readonly options = {};
async process(args: ProcessArgs) {
const newTargets = [...args.targets];
if (newTargets.length > 0 && newTargets[0].type === 'USER_PROMPT') {
const prompt = newTargets[0];
const newParts = [...prompt.semanticParts];
if (newParts.length > 0 && newParts[0].type === 'text') {
newParts[0] = {
...newParts[0],
text: newParts[0].text + ' [modified]',
};
}
newTargets[0] = {
...prompt,
id: prompt.id + '-modified',
replacesId: prompt.id,
semanticParts: newParts,
};
}
return newTargets;
}
}
// A processor that just throws an error
class ThrowingProcessor implements ContextProcessor {
static create() {
return new ThrowingProcessor();
}
constructor() {}
readonly name = 'Throwing';
readonly id = 'Throwing';
readonly options = {};
async process(): Promise<readonly ConcreteNode[]> {
throw new Error('Processor failed intentionally');
}
}
// A mock worker that signals it ran
class MockWorker implements ContextWorker {
static create() {
return new MockWorker();
}
constructor() {}
readonly name = 'MockWorker';
readonly id = 'MockWorker';
readonly triggers = {
onNodesAdded: true,
};
wasExecuted = false;
async execute(args: {
targets: readonly ConcreteNode[];
inbox: InboxSnapshot;
}) {
this.wasExecuted = true;
if (args.targets.length > 0 && args.targets[0].type === 'USER_PROMPT') {
const prompt = args.targets[0];
if (prompt.semanticParts[0].type === 'text') {
args.inbox.consume('test');
}
}
}
}
describe('PipelineOrchestrator (Component)', () => {
let env: ContextEnvironment;
let eventBus: ContextEventBus;
let registry: SidecarRegistry;
beforeEach(() => {
vi.resetAllMocks();
env = createMockEnvironment();
eventBus = env.eventBus;
registry = new SidecarRegistry();
registry.registerProcessor({
id: 'ModifyingProcessor',
schema: {},
create: () => new ModifyingProcessor(),
});
registry.registerProcessor({
id: 'ThrowingProcessor',
schema: {},
create: () => new ThrowingProcessor(),
});
registry.registerWorker({
id: 'MockWorker',
schema: {},
create: () => new MockWorker(),
});
});
afterEach(() => {
registry.clear();
});
const createConfig = (
pipelines: PipelineDef[],
workers?: Array<{ workerId: string }>,
): SidecarConfig => ({
budget: { maxTokens: 100, retainedTokens: 50 },
pipelines,
workers,
});
it('instantiates processors and workers from the registry on initialization', () => {
const config = createConfig(
[
{
name: 'SyncPipe',
triggers: ['new_message'],
processors: [
{ processorId: 'ModifyingProcessor' } as unknown as ProcessorConfig,
],
},
],
[{ workerId: 'MockWorker' }],
);
const orchestrator = new PipelineOrchestrator(
config,
env,
eventBus,
env.tracer,
registry,
);
expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(orchestrator as any).instantiatedProcessors.has('ModifyingProcessor'),
).toBe(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((orchestrator as any).instantiatedWorkers.has('MockWorker')).toBe(
true,
);
});
it('throws an error if a config requests an unknown processor', () => {
const config = createConfig([
{
name: 'ThrowPipe',
triggers: ['new_message'],
processors: [
{ processorId: 'DoesNotExist' } as unknown as ProcessorConfig,
],
},
]);
expect(
() =>
new PipelineOrchestrator(config, env, eventBus, env.tracer, registry),
).toThrow('Context Processor [DoesNotExist] is not registered.');
});
it('executes synchronous routes (executeTriggerSync) and returns modified array', async () => {
const config = createConfig([
{
name: 'SyncPipe',
triggers: ['new_message'],
processors: [
{ processorId: 'ModifyingProcessor' } as unknown as ProcessorConfig,
],
},
]);
const orchestrator = new PipelineOrchestrator(
config,
env,
eventBus,
env.tracer,
registry,
);
const nodes = [
createDummyNode(
'not-protected-ep',
'USER_PROMPT',
100,
{
semanticParts: [{ type: 'text', text: 'original text' }],
},
'not-protected-id',
),
];
const result = await orchestrator.executeTriggerSync(
'new_message',
nodes,
new Set(nodes.map((s) => s.id)),
new Set(),
);
expect(result).toHaveLength(1);
const modifiedPrompt = result[0] as UserPrompt;
assert(
modifiedPrompt.semanticParts[0].type === 'text',
'Expected a text part',
);
expect(modifiedPrompt.semanticParts[0].text).toBe(
'original text [modified]',
);
});
it('gracefully handles and swallows processor errors in synchronous routes', async () => {
const config = createConfig([
{
name: 'ThrowPipe',
triggers: ['new_message'],
processors: [
{ processorId: 'ThrowingProcessor' } as unknown as ProcessorConfig,
],
},
]);
const orchestrator = new PipelineOrchestrator(
config,
env,
eventBus,
env.tracer,
registry,
);
const nodes = [
createDummyNode(
'not-protected-ep',
'USER_PROMPT',
100,
undefined,
'not-protected-id',
),
];
// It should not throw! It should swallow the error and return the unmodified array.
const result = await orchestrator.executeTriggerSync(
'new_message',
nodes,
new Set(nodes.map((s) => s.id)),
new Set(),
);
expect(result).toHaveLength(1);
expect(result).toStrictEqual(nodes);
});
it('automatically dispatches workers when matching EventBus events occur', async () => {
const config = createConfig([], [{ workerId: 'MockWorker' }]);
const orchestrator = new PipelineOrchestrator(
config,
env,
eventBus,
env.tracer,
registry,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const workerInstance = (orchestrator as any).instantiatedWorkers.get(
'MockWorker',
) as MockWorker;
expect(workerInstance.wasExecuted).toBe(false);
const nodes = [
createDummyNode(
'not-protected-ep',
'USER_PROMPT',
100,
{
semanticParts: [{ type: 'text', text: 'worker trigger text' }],
},
'not-protected-id',
),
];
// Emit the new_message chunk which maps to onNodesAdded for workers
eventBus.emitChunkReceived({
nodes,
targetNodeIds: new Set(nodes.map((n) => n.id)),
});
// Worker execute is fire and forget, so we yield to the event loop
await new Promise((resolve) => setTimeout(resolve, 10));
expect(workerInstance.wasExecuted).toBe(true);
});
});
@@ -0,0 +1,263 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ConcreteNode } from '../ir/types.js';
import type { ContextProcessor, ContextWorker } from '../pipeline.js';
import type { SidecarConfig, PipelineDef, PipelineTrigger } from './types.js';
import type {
ContextEnvironment,
ContextEventBus,
ContextTracer,
} from './environment.js';
import type { SidecarRegistry } from './registry.js';
import { debugLogger } from '../../utils/debugLogger.js';
import { InboxSnapshotImpl } from './inbox.js';
import { ContextWorkingBufferImpl } from './contextWorkingBuffer.js';
export class PipelineOrchestrator {
private activeTimers: NodeJS.Timeout[] = [];
private readonly instantiatedProcessors = new Map<string, ContextProcessor>();
private readonly instantiatedWorkers = new Map<string, ContextWorker>();
constructor(
private readonly config: SidecarConfig,
private readonly env: ContextEnvironment,
private readonly eventBus: ContextEventBus,
private readonly tracer: ContextTracer,
private readonly registry: SidecarRegistry,
) {
this.instantiateProcessors();
this.instantiateWorkers();
this.setupTriggers();
}
private isNodeAllowed(
node: ConcreteNode,
triggerTargets: ReadonlySet<string>,
protectedLogicalIds: ReadonlySet<string> = new Set(),
): boolean {
return (
triggerTargets.has(node.id) &&
!protectedLogicalIds.has(node.id) &&
(!node.logicalParentId || !protectedLogicalIds.has(node.logicalParentId))
);
}
private instantiateProcessors() {
for (const pipeline of this.config.pipelines) {
for (const procDef of pipeline.processors) {
if (!this.instantiatedProcessors.has(procDef.processorId)) {
const factory = this.registry.getProcessor(procDef.processorId);
const instance = factory.create(this.env, procDef.options || {});
this.instantiatedProcessors.set(procDef.processorId, instance);
}
}
}
}
private instantiateWorkers() {
if (!this.config.workers) return;
for (const workerDef of this.config.workers) {
if (!this.instantiatedWorkers.has(workerDef.workerId)) {
const factory = this.registry.getWorker(workerDef.workerId);
const instance = factory.create(this.env, workerDef.options || {});
this.instantiatedWorkers.set(workerDef.workerId, instance);
}
}
}
private setupTriggers() {
// 1. Pipeline Triggers
for (const pipeline of this.config.pipelines) {
for (const trigger of pipeline.triggers) {
if (typeof trigger === 'object' && trigger.type === 'timer') {
const timer = setInterval(() => {
// Background timers not fully implemented in V1 yet
}, trigger.intervalMs);
this.activeTimers.push(timer);
} else if (trigger === 'retained_exceeded') {
this.eventBus.onConsolidationNeeded((event) => {
void this.executePipelineAsync(
pipeline,
event.nodes,
event.targetNodeIds,
new Set(), // protected IDs
);
});
} else if (trigger === 'new_message') {
this.eventBus.onChunkReceived((event) => {
void this.executePipelineAsync(
pipeline,
event.nodes,
event.targetNodeIds,
new Set(), // protected IDs
);
});
}
}
}
// 2. Worker Triggers (onNodesAdded / onNodesAgedOut)
this.eventBus.onChunkReceived((event) => {
// Fire all workers that care about new nodes
for (const worker of this.instantiatedWorkers.values()) {
if (worker.triggers.onNodesAdded) {
const inboxSnapshot = new InboxSnapshotImpl(
this.env.inbox.getMessages() || [],
);
const targets = event.nodes.filter((n) =>
event.targetNodeIds.has(n.id),
);
// Fire and forget
worker.execute({ targets, inbox: inboxSnapshot }).catch((e) => {
debugLogger.error(`Worker ${worker.name} failed onNodesAdded:`, e);
});
}
}
});
this.eventBus.onConsolidationNeeded((event) => {
// Fire all workers that care about aged out nodes
for (const worker of this.instantiatedWorkers.values()) {
if (worker.triggers.onNodesAgedOut) {
const inboxSnapshot = new InboxSnapshotImpl(
this.env.inbox.getMessages() || [],
);
const targets = event.nodes.filter((n) =>
event.targetNodeIds.has(n.id),
);
// Fire and forget
worker.execute({ targets, inbox: inboxSnapshot }).catch((e) => {
debugLogger.error(
`Worker ${worker.name} failed onNodesAgedOut:`,
e,
);
});
}
}
});
// We don't have a formal event bus for inbox publish yet, but we will soon.
// For now the workers are just registered.
}
shutdown() {
for (const timer of this.activeTimers) {
clearInterval(timer);
}
}
async executeTriggerSync(
trigger: PipelineTrigger,
nodes: readonly ConcreteNode[],
triggerTargets: ReadonlySet<string>,
protectedLogicalIds: ReadonlySet<string> = new Set(),
): Promise<readonly ConcreteNode[]> {
let currentBuffer = ContextWorkingBufferImpl.initialize(nodes);
const pipelines = this.config.pipelines.filter((p) =>
p.triggers.includes(trigger),
);
// Freeze the inbox for this pipeline run
const inboxSnapshot = new InboxSnapshotImpl(
this.env.inbox.getMessages() || [],
);
for (const pipeline of pipelines) {
for (const procDef of pipeline.processors) {
const processor = this.instantiatedProcessors.get(procDef.processorId);
if (!processor) continue;
try {
this.tracer.logEvent(
'Orchestrator',
`Executing processor synchronously: ${procDef.processorId}`,
);
const allowedTargets = currentBuffer.nodes.filter((n) =>
this.isNodeAllowed(n, triggerTargets, protectedLogicalIds),
);
const returnedNodes = await processor.process({
buffer: currentBuffer,
targets: allowedTargets,
inbox: inboxSnapshot,
});
currentBuffer = currentBuffer.applyProcessorResult(
processor.id,
allowedTargets,
returnedNodes,
);
} catch (error) {
debugLogger.error(
`Synchronous processor ${procDef.processorId} failed:`,
error,
);
}
}
}
// Success! Drain consumed messages
this.env.inbox.drainConsumed(inboxSnapshot.getConsumedIds());
return currentBuffer.nodes;
}
private async executePipelineAsync(
pipeline: PipelineDef,
nodes: readonly ConcreteNode[],
triggerTargets: Set<string>,
protectedLogicalIds: ReadonlySet<string> = new Set(),
) {
this.tracer.logEvent(
'Orchestrator',
`Triggering async pipeline: ${pipeline.name}`,
);
if (!nodes || nodes.length === 0) return;
let currentBuffer = ContextWorkingBufferImpl.initialize(nodes);
const inboxSnapshot = new InboxSnapshotImpl(
this.env.inbox.getMessages() || [],
);
for (const procDef of pipeline.processors) {
const processor = this.instantiatedProcessors.get(procDef.processorId);
if (!processor) continue;
try {
this.tracer.logEvent(
'Orchestrator',
`Executing processor: ${procDef.processorId} (async)`,
);
const allowedTargets = currentBuffer.nodes.filter((n) =>
this.isNodeAllowed(n, triggerTargets, protectedLogicalIds),
);
const returnedNodes = await processor.process({
buffer: currentBuffer,
targets: allowedTargets,
inbox: inboxSnapshot,
});
currentBuffer = currentBuffer.applyProcessorResult(
processor.id,
allowedTargets,
returnedNodes,
);
} catch (error) {
debugLogger.error(
`Pipeline ${pipeline.name} failed async at ${procDef.processorId}:`,
error,
);
return;
}
}
this.env.inbox.drainConsumed(inboxSnapshot.getConsumedIds());
}
}
@@ -0,0 +1,63 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { SidecarConfig } from './types.js';
/**
* The standard default context management profile.
* Optimized for safety, precision, and reliable summarization.
*/
export const defaultSidecarProfile: SidecarConfig = {
budget: {
retainedTokens: 65000,
maxTokens: 150000,
},
workers: [
{
workerId: 'StateSnapshotWorker',
options: {
type: 'accumulate',
},
},
],
pipelines: [
{
name: 'Immediate Sanitization',
triggers: ['new_message'],
processors: [
{
processorId: 'ToolMaskingProcessor',
options: { stringLengthThresholdTokens: 8000 },
},
{ processorId: 'BlobDegradationProcessor', options: {} },
],
},
{
name: 'Normalization',
triggers: ['retained_exceeded'],
processors: [
{
processorId: 'NodeTruncationProcessor',
options: { maxTokensPerNode: 3000 },
},
{
processorId: 'NodeDistillationProcessor',
options: { nodeThresholdTokens: 5000 },
},
],
},
{
name: 'Emergency Backstop',
triggers: ['gc_backstop'],
processors: [
{
processorId: 'StateSnapshotProcessor',
options: { target: 'max' },
},
],
},
],
};
@@ -0,0 +1,89 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { SidecarRegistry } from './registry.js';
import type { ContextProcessorDef, ContextWorkerDef } from './registry.js';
import type { ContextProcessor, ContextWorker } from '../pipeline.js';
describe('SidecarRegistry', () => {
it('should register and retrieve processors correctly', () => {
const registry = new SidecarRegistry();
const processorDef: ContextProcessorDef = {
id: 'TestProcessor',
schema: { type: 'object' },
create: () => ({}) as ContextProcessor,
};
registry.registerProcessor(processorDef);
const retrieved = registry.getProcessor('TestProcessor');
expect(retrieved).toBe(processorDef);
});
it('should register and retrieve workers correctly', () => {
const registry = new SidecarRegistry();
const workerDef: ContextWorkerDef = {
id: 'TestWorker',
schema: { type: 'object' },
create: () => ({}) as ContextWorker,
};
registry.registerWorker(workerDef);
const retrieved = registry.getWorker('TestWorker');
expect(retrieved).toBe(workerDef);
});
it('should throw an error when retrieving unregistered processors', () => {
const registry = new SidecarRegistry();
expect(() => registry.getProcessor('Unknown')).toThrow(
'Context Processor [Unknown] is not registered.',
);
});
it('should throw an error when retrieving unregistered workers', () => {
const registry = new SidecarRegistry();
expect(() => registry.getWorker('Unknown')).toThrow(
'Context Worker [Unknown] is not registered.',
);
});
it('should return combined schemas', () => {
const registry = new SidecarRegistry();
registry.registerProcessor({
id: 'TestProcessor',
schema: { title: 'processorSchema' },
create: () => ({}) as ContextProcessor,
});
registry.registerWorker({
id: 'TestWorker',
schema: { title: 'workerSchema' },
create: () => ({}) as ContextWorker,
});
const schemas = registry.getSchemas() as Array<{ title?: string }>;
expect(schemas.length).toBe(2);
expect(schemas.find((s) => s.title === 'processorSchema')).toBeDefined();
expect(schemas.find((s) => s.title === 'workerSchema')).toBeDefined();
});
it('should safely clear the registry', () => {
const registry = new SidecarRegistry();
registry.registerProcessor({
id: 'TestProcessor',
schema: {},
create: () => ({}) as ContextProcessor,
});
registry.registerWorker({
id: 'TestWorker',
schema: {},
create: () => ({}) as ContextWorker,
});
registry.clear();
expect(() => registry.getProcessor('TestProcessor')).toThrow();
expect(() => registry.getWorker('TestWorker')).toThrow();
});
});
@@ -0,0 +1,68 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ContextProcessor, ContextWorker } from '../pipeline.js';
import type { ContextEnvironment } from './environment.js';
export interface ContextProcessorDef<TOptions = object> {
readonly id: string;
readonly schema: object;
create(env: ContextEnvironment, options: TOptions): ContextProcessor;
}
export interface ContextWorkerDef<TOptions = object> {
readonly id: string;
readonly schema: object;
create(env: ContextEnvironment, options: TOptions): ContextWorker;
}
/**
* Registry for mapping declarative sidecar configs to running components.
*/
export class SidecarRegistry {
private processors = new Map<string, ContextProcessorDef<unknown>>();
private workers = new Map<string, ContextWorkerDef<unknown>>();
registerProcessor<TOptions>(def: ContextProcessorDef<TOptions>) {
this.processors.set(def.id, def);
}
registerWorker<TOptions>(def: ContextWorkerDef<TOptions>) {
this.workers.set(def.id, def);
}
getProcessor(id: string): ContextProcessorDef {
const def = this.processors.get(id);
if (!def) {
throw new Error(`Context Processor [${id}] is not registered.`);
}
return def;
}
getWorker(id: string): ContextWorkerDef {
const def = this.workers.get(id);
if (!def) {
throw new Error(`Context Worker [${id}] is not registered.`);
}
return def;
}
getSchemas(): object[] {
const schemas: object[] = [];
for (const def of this.processors.values()) {
if (def.schema) schemas.push(def.schema);
}
for (const def of this.workers.values()) {
if (def.schema) schemas.push(def.schema);
}
return schemas;
}
clear() {
this.processors.clear();
this.workers.clear();
}
}
@@ -0,0 +1,92 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { SidecarRegistry } from './registry.js';
import './builtins.js';
export function getSidecarConfigSchema(registry: SidecarRegistry) {
return {
$schema: 'http://json-schema.org/draft-07/schema#',
title: 'SidecarConfig',
description: 'The Data-Driven Schema for the Context Manager.',
type: 'object',
required: ['budget', 'pipelines'],
properties: {
budget: {
type: 'object',
description: 'Defines the token ceilings and limits for the pipeline.',
required: ['retainedTokens', 'maxTokens'],
properties: {
retainedTokens: {
type: 'number',
description:
'The ideal token count the pipeline tries to shrink down to.',
},
maxTokens: {
type: 'number',
description:
'The absolute maximum token count allowed before synchronous truncation kicks in.',
},
},
},
workers: {
type: 'array',
description: 'Background workers that proactively accumulate context.',
items: {
type: 'object',
required: ['workerId'],
properties: {
workerId: { type: 'string' },
options: { type: 'object' },
},
},
},
pipelines: {
type: 'array',
description: 'The execution graphs for context manipulation.',
items: {
type: 'object',
required: ['name', 'triggers', 'execution', 'processors'],
properties: {
name: {
type: 'string',
},
triggers: {
type: 'array',
items: {
anyOf: [
{
type: 'string',
enum: ['new_message', 'retained_exceeded', 'gc_backstop'],
},
{
type: 'object',
required: ['type', 'intervalMs'],
properties: {
type: {
type: 'string',
const: 'timer',
},
intervalMs: {
type: 'number',
},
},
},
],
},
},
processors: {
type: 'array',
items: {
oneOf: registry.getSchemas(),
},
},
},
},
},
},
};
}
@@ -0,0 +1,65 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Definition of a processor or worker to be instantiated in the graph.
*/
export type ProcessorConfig =
| {
processorId: 'ToolMaskingProcessor';
options: { stringLengthThresholdTokens: number };
}
| { processorId: 'BlobDegradationProcessor'; options?: object }
| {
processorId: 'NodeDistillationProcessor';
options: { nodeThresholdTokens: number };
}
| {
processorId: 'NodeTruncationProcessor';
options: { maxTokensPerNode: number };
}
| {
processorId: 'StateSnapshotProcessor';
options?: Record<string, unknown>;
}
| {
processorId: 'HistoryTruncationProcessor';
options?: Record<string, unknown>;
};
export interface WorkerConfig {
workerId: string;
options?: Record<string, unknown>;
}
export type PipelineTrigger =
| 'new_message'
| 'retained_exceeded'
| 'gc_backstop'
| { type: 'timer'; intervalMs: number };
export interface PipelineDef {
name: string;
triggers: PipelineTrigger[];
processors: ProcessorConfig[];
}
/**
* The Data-Driven Schema for the Context Manager.
*/
export interface SidecarConfig {
/** Defines the token ceilings and limits for the pipeline. */
budget: {
retainedTokens: number;
maxTokens: number;
};
/** The execution graphs for context manipulation */
pipelines: PipelineDef[];
/** Background actors that generate data for pipelines */
workers?: WorkerConfig[];
}
@@ -0,0 +1,164 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { ContextManager } from '../contextManager.js';
import { AgentChatHistory } from '../../core/agentChatHistory.js';
import type { Content } from '@google/genai';
import type { SidecarConfig } from '../sidecar/types.js';
import { ContextEnvironmentImpl } from '../sidecar/environmentImpl.js';
import { ContextTracer } from '../tracer.js';
import { ContextEventBus } from '../eventBus.js';
import { PipelineOrchestrator } from '../sidecar/orchestrator.js';
import { registerBuiltInProcessors } from '../sidecar/builtins.js';
import { debugLogger } from '../../utils/debugLogger.js';
import { SidecarRegistry } from '../sidecar/registry.js';
import { DeterministicIdGenerator } from '../system/DeterministicIdGenerator.js';
import { InMemoryFileSystem } from '../system/InMemoryFileSystem.js';
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
export interface TurnSummary {
turnIndex: number;
tokensBeforeBackground: number;
tokensAfterBackground: number;
}
export class SimulationHarness {
readonly chatHistory: AgentChatHistory;
contextManager!: ContextManager;
env!: ContextEnvironmentImpl;
orchestrator!: PipelineOrchestrator;
readonly eventBus: ContextEventBus;
config!: SidecarConfig;
private tracer!: ContextTracer;
private currentTurnIndex = 0;
private tokenTrajectory: TurnSummary[] = [];
static async create(
config: SidecarConfig,
mockLlmClient: BaseLlmClient,
mockTempDir = '/tmp/sim',
): Promise<SimulationHarness> {
const harness = new SimulationHarness();
await harness.init(config, mockLlmClient, mockTempDir);
return harness;
}
private constructor() {
this.chatHistory = new AgentChatHistory();
this.eventBus = new ContextEventBus();
}
private async init(
config: SidecarConfig,
mockLlmClient: BaseLlmClient,
mockTempDir: string,
) {
this.config = config;
const registry = new SidecarRegistry();
// Register all standard processors
registerBuiltInProcessors(registry);
this.tracer = new ContextTracer({
targetDir: mockTempDir,
sessionId: 'sim-session',
});
this.env = new ContextEnvironmentImpl(
mockLlmClient,
'sim-prompt',
'sim-session',
mockTempDir,
mockTempDir,
this.tracer,
1, // 1 char per token average
this.eventBus,
new InMemoryFileSystem(),
new DeterministicIdGenerator(),
);
this.orchestrator = new PipelineOrchestrator(
config,
this.env,
this.eventBus,
this.tracer,
registry,
);
this.contextManager = new ContextManager(
config,
this.env,
this.tracer,
this.orchestrator,
this.chatHistory,
);
}
/**
* Simulates a single "Turn" (User input + Model/Tool outputs)
* A turn might consist of multiple Content messages (e.g. user prompt -> model call -> user response -> model answer)
*/
async simulateTurn(messages: Content[]) {
// 1. Append the new messages
const currentHistory = this.chatHistory.get();
this.chatHistory.set([...currentHistory, ...messages]);
// 2. Measure tokens immediately after append (Before background processing)
const tokensBefore = this.env.tokenCalculator.calculateConcreteListTokens(
this.contextManager.getNodes(),
);
debugLogger.log(
`[Turn ${this.currentTurnIndex}] Tokens BEFORE: ${tokensBefore}`,
);
// 3. Yield to event loop to allow internal async subscribers and orchestrator to finish
await new Promise((resolve) => setTimeout(resolve, 50));
// 3.1 Simulate what projectCompressedHistory does with the sync handlers
let currentView = this.contextManager.getNodes();
const currentTokens =
this.env.tokenCalculator.calculateConcreteListTokens(currentView);
if (this.config.budget && currentTokens > this.config.budget.maxTokens) {
debugLogger.log(
`[Turn ${this.currentTurnIndex}] Sync panic triggered! ${currentTokens} > ${this.config.budget.maxTokens}`,
);
const orchestrator = this.orchestrator;
// In the V2 simulation, we trigger the 'gc_backstop' to simulate emergency pressure.
// Since contextManager owns its buffer natively, the simulation now properly matches reality
// where the manager runs the orchestrator and keeps the resulting modified view.
const modifiedView = await orchestrator.executeTriggerSync(
'gc_backstop',
currentView,
new Set(currentView.map((e) => e.id)),
new Set<string>(),
);
// In the real system, ContextManager triggers this and retains it.
// We will emulate that behavior internally in the test loop for token counting.
currentView = modifiedView;
}
// 4. Measure tokens after background processors have processed inboxes
const tokensAfter = this.env.tokenCalculator.calculateConcreteListTokens(
this.contextManager.getNodes(),
);
debugLogger.log(
`[Turn ${this.currentTurnIndex}] Tokens AFTER: ${tokensAfter}`,
);
this.tokenTrajectory.push({
turnIndex: this.currentTurnIndex++,
tokensBeforeBackground: tokensBefore,
tokensAfterBackground: tokensAfter,
});
}
async getGoldenState() {
const finalProjection =
await this.contextManager.projectCompressedHistory();
return {
tokenTrajectory: this.tokenTrajectory,
finalProjection,
};
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,210 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { SimulationHarness } from './SimulationHarness.js';
import { createMockLlmClient } from '../testing/contextTestUtils.js';
import type { SidecarConfig } from '../sidecar/types.js';
expect.addSnapshotSerializer({
test: (val) =>
typeof val === 'string' &&
(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
val,
) ||
/^\/tmp\/sim/.test(val)), // Mask temp directories and UUIDs
print: (val) =>
typeof val === 'string' && /^\/tmp\/sim/.test(val)
? '"<MOCKED_DIR>"'
: '"<UUID>"',
});
describe('System Lifecycle Golden Tests', () => {
beforeAll(() => {
vi.spyOn(Math, 'random').mockReturnValue(0.5);
});
afterAll(() => {
vi.restoreAllMocks();
});
const getAggressiveConfig = (): SidecarConfig => ({
budget: { maxTokens: 1000, retainedTokens: 500 }, // Extremely tight limits
pipelines: [
{
name: 'Pressure Relief', // Emits from eventBus 'retained_exceeded'
triggers: ['retained_exceeded'],
processors: [
{ processorId: 'BlobDegradationProcessor' },
{
processorId: 'ToolMaskingProcessor',
options: { stringLengthThresholdTokens: 50 },
}, // Mask any tool string > 50 chars
{ processorId: 'StateSnapshotProcessor', options: {} }, // Squash old history
],
},
{
name: 'Immediate Sanitization', // The magic string the projector is hardcoded to use
triggers: ['retained_exceeded'],
processors: [
{ processorId: 'HistoryTruncationProcessor', options: {} },
],
},
],
workers: [{ workerId: 'StateSnapshotWorker' }],
});
const mockLlmClient = createMockLlmClient([
'<MOCKED_STATE_SNAPSHOT_SUMMARY>',
]);
it('Scenario 1: Organic Growth with Huge Tool Output & Images', async () => {
const harness = await SimulationHarness.create(
getAggressiveConfig(),
mockLlmClient,
);
// Turn 0: System Prompt
await harness.simulateTurn([
{ role: 'user', parts: [{ text: 'System Instructions' }] },
{ role: 'model', parts: [{ text: 'Ack.' }] },
]);
// Turn 1: Normal conversation
await harness.simulateTurn([
{ role: 'user', parts: [{ text: 'Hello!' }] },
{ role: 'model', parts: [{ text: 'Hi, how can I help?' }] },
]);
// Turn 2: Massive Tool Output (Should trigger ToolMaskingProcessor in background)
await harness.simulateTurn([
{ role: 'user', parts: [{ text: 'Read the logs.' }] },
{
role: 'model',
parts: [
{
functionCall: {
name: 'run_shell_command',
args: { cmd: 'cat server.log' },
},
},
],
},
{
role: 'user',
parts: [
{
functionResponse: {
name: 'run_shell_command',
response: { output: 'LOG '.repeat(5000) },
},
},
],
},
{ role: 'model', parts: [{ text: 'The logs are very long.' }] },
]);
// Turn 3: Multi-modal blob (Should trigger BlobDegradationProcessor)
await harness.simulateTurn([
{
role: 'user',
parts: [
{ text: 'Look at this architecture diagram:' },
{
inlineData: {
mimeType: 'image/png',
data: 'fake_base64_data_'.repeat(1000),
},
},
],
},
{ role: 'model', parts: [{ text: 'Nice diagram.' }] },
]);
// Turn 4: More conversation to trigger StateSnapshot
await harness.simulateTurn([
{ role: 'user', parts: [{ text: 'Can we refactor?' }] },
{ role: 'model', parts: [{ text: 'Yes we can.' }] },
]);
// Get final state
const goldenState = await harness.getGoldenState();
// In a perfectly functioning opportunistic system, the token trajectory should show
// the massive spikes in Turn 2 and 3 being immediately resolved by the background tasks.
// The final projection should fit neatly under the Max Tokens limit.
expect(goldenState).toMatchSnapshot();
});
it('Scenario 2: Under Budget (No Modifications)', async () => {
const generousConfig: SidecarConfig = {
budget: { maxTokens: 100000, retainedTokens: 50000 },
pipelines: [], // No triggers
workers: [],
};
const harness = await SimulationHarness.create(
generousConfig,
mockLlmClient,
);
// Turn 0: System Prompt
await harness.simulateTurn([
{ role: 'user', parts: [{ text: 'System Instructions' }] },
{ role: 'model', parts: [{ text: 'Ack.' }] },
]);
// Turn 1: Normal conversation
await harness.simulateTurn([
{ role: 'user', parts: [{ text: 'Hello!' }] },
{ role: 'model', parts: [{ text: 'Hi, how can I help?' }] },
]);
const goldenState = await harness.getGoldenState();
// Total tokens should cleanly match character count with no synthetic nodes
expect(goldenState).toMatchSnapshot();
});
it('Scenario 3: Worker-Driven Background GC', async () => {
const gcConfig: SidecarConfig = {
budget: { maxTokens: 200, retainedTokens: 100 },
pipelines: [], // No standard pipelines
workers: [
{ workerId: 'StateSnapshotWorker' }, // This should fire on chunk events
],
};
const harness = await SimulationHarness.create(gcConfig, mockLlmClient);
// Turn 0
await harness.simulateTurn([
{ role: 'user', parts: [{ text: 'A'.repeat(50) }] },
{ role: 'model', parts: [{ text: 'B'.repeat(50) }] },
]);
// Turn 1 (Should trigger StateSnapshotWorker because we exceed 100 retainedTokens)
await harness.simulateTurn([
{ role: 'user', parts: [{ text: 'C'.repeat(50) }] },
{ role: 'model', parts: [{ text: 'D'.repeat(50) }] },
]);
// Give the background worker 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) }] },
]);
const goldenState = await harness.getGoldenState();
// We should see ROLLING_SUMMARY nodes injected into the graph, proving the worker ran in the background
expect(goldenState).toMatchSnapshot();
});
});
@@ -0,0 +1,18 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { IIdGenerator } from './IIdGenerator.js';
export class DeterministicIdGenerator implements IIdGenerator {
private counter = 0;
constructor(private prefix: string = 'id-') {}
generateId(): string {
this.counter++;
return `${this.prefix}${this.counter}`;
}
}
@@ -0,0 +1,20 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export interface IFileSystem {
existsSync(path: string): boolean;
statSyncSize(path: string): number;
readFileSync(path: string, encoding: 'utf8'): string;
writeFileSync(path: string, data: string | Buffer, encoding?: 'utf-8'): void;
appendFileSync(path: string, data: string, encoding: 'utf-8'): void;
mkdirSync(path: string, options?: { recursive?: boolean }): void;
writeFile(path: string, data: string | Buffer): Promise<void>;
mkdir(path: string, options?: { recursive?: boolean }): Promise<void>;
join(...paths: string[]): string;
dirname(path: string): string;
}
@@ -0,0 +1,9 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export interface IIdGenerator {
generateId(): string;
}
@@ -0,0 +1,79 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { IFileSystem } from './IFileSystem.js';
export class InMemoryFileSystem implements IFileSystem {
private files = new Map<string, string | Buffer>();
getFiles(): ReadonlyMap<string, string | Buffer> {
return this.files;
}
setFile(path: string, content: string | Buffer) {
this.files.set(this.normalize(path), content);
}
private normalize(p: string): string {
return p.replace(/\/+/g, '/');
}
existsSync(p: string): boolean {
return this.files.has(this.normalize(p));
}
statSyncSize(p: string): number {
const content = this.files.get(this.normalize(p));
if (content === undefined) {
throw new Error(`ENOENT: no such file or directory, stat '${p}'`);
}
return Buffer.isBuffer(content)
? content.byteLength
: Buffer.byteLength(content, 'utf8');
}
readFileSync(p: string, encoding: 'utf8'): string {
const content = this.files.get(this.normalize(p));
if (content === undefined) {
throw new Error(`ENOENT: no such file or directory, open '${p}'`);
}
if (Buffer.isBuffer(content)) {
return content.toString(encoding);
}
return content;
}
writeFileSync(p: string, data: string | Buffer, _encoding?: 'utf-8'): void {
this.files.set(this.normalize(p), data);
}
appendFileSync(p: string, data: string, _encoding: 'utf-8'): void {
const norm = this.normalize(p);
const existing = this.files.get(norm) || '';
const existingStr = Buffer.isBuffer(existing)
? existing.toString('utf8')
: existing;
this.files.set(norm, existingStr + data);
}
mkdirSync(_p: string, _options?: { recursive?: boolean }): void {}
async writeFile(p: string, data: string | Buffer): Promise<void> {
this.writeFileSync(p, data);
}
async mkdir(_p: string, _options?: { recursive?: boolean }): Promise<void> {}
join(...paths: string[]): string {
return this.normalize(paths.join('/'));
}
dirname(p: string): string {
const parts = this.normalize(p).split('/');
parts.pop();
return parts.length === 0 ? '.' : parts.join('/') || '/';
}
}

Some files were not shown because too many files have changed in this diff Show More