Compare commits

..

23 Commits

Author SHA1 Message Date
Keith Schaab 74523f82c1 Merge branch 'main' into handle-symlinks 2026-04-14 18:40:26 +00:00
ruomeng 02792264ed feat(plan): update plan mode prompt to allow showing plan content (#25058) 2026-04-14 17:36:37 +00:00
Emily Hedlund 059d9175eb test(core): improve sandbox integration test coverage and fix OS-specific failures (#25307)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-04-14 17:33:07 +00:00
Jack Wotherspoon 212edf31ed chore(mcp): check MCP error code over brittle string match (#25381) 2026-04-14 17:24:21 +00:00
Keith Schaab b55320926d fix(core): support symlinks for workspace policies and handle broken links gracefully
Updates the policy TOML loader to correctly follow symlinked files and directories. This ensures that users can symbolically link policies across workspaces or fallback paths without the loader failing.

Key changes:
- Refactored readPolicyFiles to follow symlinks using fs.realpath.
- Added recursion for symlinked directories with a visitedPaths tracker to prevent infinite circular traversal.
- Added error handling within the directory scanning loop to catch and ignore ENOENT errors, ensuring that broken symlinks do not silently abort the loading of other valid policies in the same directory.
- Added comprehensive unit tests for standard symlink behavior, circular symlink protection, and broken symlink resilience.
- Updated mocked fs calls in tests to support realpath.
2026-04-14 17:12:16 +00:00
Abhi 1bb41262b0 docs(core): update generalist agent documentation (#25325) 2026-04-14 01:29:19 +00:00
joshualitt daf5006237 feat(core): introduce decoupled ContextManager and Sidecar architecture (#24752) 2026-04-13 22:02:22 +00:00
Christian Gunderman 706d4d4707 fix(core): prevent secondary crash in ModelRouterService finally block (#25333) 2026-04-13 20:57:01 +00:00
Kevin Zhao 24f9ec51d2 fix: correct redirect count increment in fetchJson (#24896)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-04-13 20:45:52 +00:00
Jerop Kipruto 050c30330e feat(core): implement silent fallback for Plan Mode model routing (#25317) 2026-04-13 19:59:24 +00:00
Tanmay Vartak a172b328e2 feat: support auth block in MCP servers config in agents (#24770) 2026-04-13 19:41:40 +00:00
Adib234 a4318f22ec fix(core): expose GEMINI_PLANS_DIR to hook environment (#25296)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-13 19:26:52 +00:00
Jacob Richman 82e8d67a78 Stop showing scrollbar unless we are in terminalBuffer mode (#25320) 2026-04-13 19:26:45 +00:00
Michael Bleigh 95944ec5af feat(agent): implement tool-controlled display protocol (Steps 2-3) (#25134) 2026-04-13 19:09:02 +00:00
Anjaligarhwal ea36ccb567 fix(core): replace custom binary detection with isbinaryfile to correctly handle UTF-8 (U+FFFD) (#25297) 2026-04-13 18:58:18 +00:00
Dev Randalpura a05c5ed56a feat(ui): added enhancements to scroll momentum (#24447) 2026-04-13 18:48:55 +00:00
Emily Hedlund 0d6d5d90b9 refactor(core): extract and centralize sandbox path utilities (#25305)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-04-13 18:43:13 +00:00
Adib234 b91d177bde feat(cli): extract QuotaContext and resolve infinite render loop (#24959) 2026-04-13 18:32:18 +00:00
Sandy Tao 36dca862cc fix(release): prefix git hash in nightly versions to prevent semver normalization (#25304) 2026-04-13 17:55:11 +00:00
Christian Gunderman a5f7b453ca Stop suppressing thoughts and text in model response (#25073) 2026-04-13 17:47:48 +00:00
Sandy Tao 26f04c9d9a feat(core): add skill patching support with /memory inbox integration (#25148) 2026-04-13 17:44:52 +00:00
Jesse Rosenstock 5d8bd41937 docs(contributing): clarify self-assignment policy for issues (#23087) 2026-04-13 17:15:54 +00:00
Jack Wotherspoon 6b6ea56437 fix(core): fix quota footer for non-auto models and improve display (#25121) 2026-04-13 17:03:41 +00:00
147 changed files with 11956 additions and 1560 deletions
+1
View File
@@ -2,6 +2,7 @@
"experimental": {
"extensionReloading": true,
"modelSteering": true,
"memoryManager": true,
"topicUpdateNarration": true
},
"general": {
+3 -1
View File
@@ -110,7 +110,9 @@ assign or unassign the issue as requested, provided the conditions are met
(e.g., an issue must be unassigned to be assigned).
Please note that you can have a maximum of 3 issues assigned to you at any given
time.
time and that only
[issues labeled "help wanted"](https://github.com/google-gemini/gemini-cli/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22help%20wanted%22)
may be self-assigned.
### Pull request guidelines
+10 -2
View File
@@ -327,8 +327,12 @@ Storage whenever Gemini CLI exits Plan Mode to start the implementation.
```bash
#!/usr/bin/env bash
# Extract the plan path from the tool input JSON
plan_path=$(jq -r '.tool_input.plan_path // empty')
# Extract the plan filename from the tool input JSON
plan_filename=$(jq -r '.tool_input.plan_filename // empty')
plan_filename=$(basename -- "$plan_filename")
# Construct the absolute path using the GEMINI_PLANS_DIR environment variable
plan_path="$GEMINI_PLANS_DIR/$plan_filename"
if [ -f "$plan_path" ]; then
# Generate a unique filename using a timestamp
@@ -441,6 +445,10 @@ on the current phase of your task:
switches to a high-speed **Flash** model. This provides a faster, more
responsive experience during the implementation of the plan.
If the high-reasoning model is unavailable or you don't have access to it,
Gemini CLI automatically and silently falls back to a faster model to ensure
your workflow isn't interrupted.
This behavior is enabled by default to provide the best balance of quality and
performance. You can disable this automatic switching in your settings:
+17 -5
View File
@@ -87,11 +87,23 @@ Gemini CLI comes with the following built-in subagents:
### Generalist Agent
- **Name:** `generalist_agent`
- **Purpose:** Route tasks to the appropriate specialized subagent.
- **When to use:** Implicitly used by the main agent for routing. Not directly
invoked by the user.
- **Configuration:** Enabled by default. No specific configuration options.
- **Name:** `generalist`
- **Purpose:** A general, all-purpose subagent that uses the inherited tool
access and configurations from the main agent. Useful for executing broad,
resource-heavy subtasks in an isolated conversation, optimizing your main
agent's context by returning only the final result of that given task.
- **When to use:** Use this agent when a task requires many steps, handles large
volumes of information, or requires the same full capabilities as the main
agent. It is ideal for:
- **Multi-file modifications:** Applying refactors or fixing errors across
several files at once.
- **High-volume execution:** Running commands or tests that produce extensive
terminal output.
- **Action-oriented research:** Investigations where the agent needs to both
search code and run commands or make edits to find a solution. By delegating
these tasks, you prevent your main conversation from becoming cluttered or
slow. You can invoke it explicitly using `@generalist`.
- **Configuration:** Enabled by default.
### Browser Agent (experimental)
+1
View File
@@ -138,6 +138,7 @@ multiple layers in the following order of precedence (highest to lowest):
Hooks are executed with a sanitized environment.
- `GEMINI_PROJECT_DIR`: The absolute path to the project root.
- `GEMINI_PLANS_DIR`: The absolute path to the plans directory.
- `GEMINI_SESSION_ID`: The unique ID for the current session.
- `GEMINI_CWD`: The current working directory.
- `CLAUDE_PROJECT_DIR`: (Alias) Provided for compatibility.
+13
View File
@@ -10839,6 +10839,18 @@
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
"license": "MIT"
},
"node_modules/isbinaryfile": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz",
"integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==",
"license": "MIT",
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/gjtorikian/"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -18187,6 +18199,7 @@
"https-proxy-agent": "^7.0.6",
"ignore": "^7.0.0",
"ipaddr.js": "^1.9.1",
"isbinaryfile": "^5.0.7",
"js-yaml": "^4.1.1",
"json-stable-stringify": "^1.3.0",
"marked": "^15.0.12",
+20 -7
View File
@@ -7,6 +7,7 @@
import {
addMemory,
listInboxSkills,
listInboxPatches,
listMemoryFiles,
refreshMemory,
showMemory,
@@ -141,22 +142,34 @@ export class InboxMemoryCommand implements Command {
};
}
const skills = await listInboxSkills(context.agentContext.config);
const [skills, patches] = await Promise.all([
listInboxSkills(context.agentContext.config),
listInboxPatches(context.agentContext.config),
]);
if (skills.length === 0) {
return { name: this.name, data: 'No extracted skills in inbox.' };
if (skills.length === 0 && patches.length === 0) {
return { name: this.name, data: 'No items in inbox.' };
}
const lines = skills.map((s) => {
const lines: string[] = [];
for (const s of skills) {
const date = s.extractedAt
? ` (extracted: ${new Date(s.extractedAt).toLocaleDateString()})`
: '';
return `- **${s.name}**: ${s.description}${date}`;
});
lines.push(`- **${s.name}**: ${s.description}${date}`);
}
for (const p of patches) {
const targets = p.entries.map((e) => e.targetPath).join(', ');
const date = p.extractedAt
? ` (extracted: ${new Date(p.extractedAt).toLocaleDateString()})`
: '';
lines.push(`- **${p.name}** (update): patches ${targets}${date}`);
}
const total = skills.length + patches.length;
return {
name: this.name,
data: `Skill inbox (${skills.length}):\n${lines.join('\n')}`,
data: `Memory inbox (${total}):\n${lines.join('\n')}`,
};
}
}
@@ -62,6 +62,7 @@ describe('fetchJson', () => {
const res = new EventEmitter() as IncomingMessage;
res.statusCode = 302;
res.headers = { location: 'https://example.com/final' };
res.resume = vi.fn();
(callback as (res: IncomingMessage) => void)(res);
res.emit('end');
return new EventEmitter() as ClientRequest;
@@ -85,6 +86,7 @@ describe('fetchJson', () => {
const res = new EventEmitter() as IncomingMessage;
res.statusCode = 301;
res.headers = { location: 'https://example.com/final-permanent' };
res.resume = vi.fn();
(callback as (res: IncomingMessage) => void)(res);
res.emit('end');
return new EventEmitter() as ClientRequest;
@@ -31,7 +31,11 @@ export async function fetchJson<T>(
if (!res.headers.location) {
return reject(new Error('No location header in redirect response'));
}
fetchJson<T>(res.headers.location, redirectCount++)
res.resume();
fetchJson<T>(
new URL(res.headers.location, url).toString(),
redirectCount + 1,
)
.then(resolve)
.catch(reject);
return;
+2 -2
View File
@@ -34,8 +34,8 @@ export const ALL_ITEMS = [
},
{
id: 'quota',
header: '/stats',
description: 'Remaining usage on daily limit (not shown when unavailable)',
header: 'quota',
description: 'Percentage of daily limit used (not shown when unavailable)',
},
{
id: 'memory-usage',
@@ -37,6 +37,7 @@ import {
LegacyAgentSession,
ToolErrorType,
geminiPartsToContentParts,
displayContentToString,
debugLogger,
} from '@google/gemini-cli-core';
@@ -470,7 +471,8 @@ export async function runNonInteractive({
case 'tool_response': {
textOutput.ensureTrailingNewline();
if (streamFormatter) {
const displayText = getTextContent(event.displayContent);
const display = event.display?.result;
const displayText = displayContentToString(display);
const errorMsg = getTextContent(event.content) ?? 'Tool error';
streamFormatter.emitEvent({
type: JsonStreamEventType.TOOL_RESULT,
@@ -490,7 +492,8 @@ export async function runNonInteractive({
});
}
if (event.isError) {
const displayText = getTextContent(event.displayContent);
const display = event.display?.result;
const displayText = displayContentToString(display);
const errorMsg = getTextContent(event.content) ?? 'Tool error';
if (event.data?.['errorType'] === ToolErrorType.STOP_EXECUTION) {
+73 -58
View File
@@ -602,6 +602,7 @@ const mockUIActions: UIActions = {
import { type TextBuffer } from '../ui/components/shared/text-buffer.js';
import { InputContext, type InputState } from '../ui/contexts/InputContext.js';
import { QuotaContext, type QuotaState } from '../ui/contexts/QuotaContext.js';
let capturedOverflowState: OverflowState | undefined;
let capturedOverflowActions: OverflowActions | undefined;
@@ -619,6 +620,7 @@ export const renderWithProviders = async (
shellFocus = true,
settings = mockSettings,
uiState: providedUiState,
quotaState: providedQuotaState,
inputState: providedInputState,
width,
mouseEventsEnabled = false,
@@ -631,6 +633,7 @@ export const renderWithProviders = async (
shellFocus?: boolean;
settings?: LoadedSettings;
uiState?: Partial<UIState>;
quotaState?: Partial<QuotaState>;
inputState?: Partial<InputState>;
width?: number;
mouseEventsEnabled?: boolean;
@@ -666,6 +669,16 @@ export const renderWithProviders = async (
},
) as UIState;
const quotaState: QuotaState = {
userTier: undefined,
stats: undefined,
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
...providedQuotaState,
};
const inputState = {
buffer: { text: '' } as unknown as TextBuffer,
userMessages: [],
@@ -727,65 +740,67 @@ export const renderWithProviders = async (
<AppContext.Provider value={appState}>
<ConfigContext.Provider value={config}>
<SettingsContext.Provider value={settings}>
<InputContext.Provider value={inputState}>
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider>
<ShellFocusContext.Provider value={shellFocus}>
<SessionStatsProvider sessionId={config.getSessionId()}>
<StreamingContext.Provider
value={finalUiState.streamingState}
>
<UIActionsContext.Provider value={finalUIActions}>
<OverflowProvider>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
isExpanded={
toolActions?.isExpanded ??
vi.fn().mockReturnValue(false)
}
toggleExpansion={
toolActions?.toggleExpansion ?? vi.fn()
}
toggleAllExpansion={
toolActions?.toggleAllExpansion ?? vi.fn()
}
>
<AskUserActionsProvider
request={null}
onSubmit={vi.fn()}
onCancel={vi.fn()}
<QuotaContext.Provider value={quotaState}>
<InputContext.Provider value={inputState}>
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider>
<ShellFocusContext.Provider value={shellFocus}>
<SessionStatsProvider sessionId={config.getSessionId()}>
<StreamingContext.Provider
value={finalUiState.streamingState}
>
<UIActionsContext.Provider value={finalUIActions}>
<OverflowProvider>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
isExpanded={
toolActions?.isExpanded ??
vi.fn().mockReturnValue(false)
}
toggleExpansion={
toolActions?.toggleExpansion ?? vi.fn()
}
toggleAllExpansion={
toolActions?.toggleAllExpansion ?? vi.fn()
}
>
<KeypressProvider>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
>
<TerminalProvider>
<ScrollProvider>
<ContextCapture>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{comp}
</Box>
</ContextCapture>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
</AskUserActionsProvider>
</ToolActionsProvider>
</OverflowProvider>
</UIActionsContext.Provider>
</StreamingContext.Provider>
</SessionStatsProvider>
</ShellFocusContext.Provider>
</VimModeProvider>
</UIStateContext.Provider>
</InputContext.Provider>
<AskUserActionsProvider
request={null}
onSubmit={vi.fn()}
onCancel={vi.fn()}
>
<KeypressProvider>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
>
<TerminalProvider>
<ScrollProvider>
<ContextCapture>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{comp}
</Box>
</ContextCapture>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
</AskUserActionsProvider>
</ToolActionsProvider>
</OverflowProvider>
</UIActionsContext.Provider>
</StreamingContext.Provider>
</SessionStatsProvider>
</ShellFocusContext.Provider>
</VimModeProvider>
</UIStateContext.Provider>
</InputContext.Provider>
</QuotaContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>
</AppContext.Provider>
+7 -83
View File
@@ -123,16 +123,19 @@ vi.mock('ink', async (importOriginal) => {
});
import { InputContext, type InputState } from './contexts/InputContext.js';
import { QuotaContext, type QuotaState } from './contexts/QuotaContext.js';
// Helper component will read the context values provided by AppContainer
// so we can assert against them in our tests.
let capturedUIState: UIState;
let capturedInputState: InputState;
let capturedQuotaState: QuotaState;
let capturedUIActions: UIActions;
let capturedOverflowActions: OverflowActions;
function TestContextConsumer() {
capturedUIState = useContext(UIStateContext)!;
capturedInputState = useContext(InputContext)!;
capturedQuotaState = useContext(QuotaContext)!;
capturedUIActions = useContext(UIActionsContext)!;
capturedOverflowActions = useOverflowActions()!;
return null;
@@ -1309,15 +1312,15 @@ describe('AppContainer State Management', () => {
});
describe('Quota and Fallback Integration', () => {
it('passes a null proQuotaRequest to UIStateContext by default', async () => {
it('passes a null proQuotaRequest to QuotaContext by default', async () => {
// The default mock from beforeEach already sets proQuotaRequest to null
const { unmount } = await act(async () => renderAppContainer());
// Assert that the context value is as expected
expect(capturedUIState.quota.proQuotaRequest).toBeNull();
expect(capturedQuotaState.proQuotaRequest).toBeNull();
unmount();
});
it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', async () => {
it('passes a valid proQuotaRequest to QuotaContext when provided by the hook', async () => {
// Arrange: Create a mock request object that a UI dialog would receive
const mockRequest = {
failedModel: 'gemini-pro',
@@ -1332,7 +1335,7 @@ describe('AppContainer State Management', () => {
// Act: Render the container
const { unmount } = await act(async () => renderAppContainer());
// Assert: The mock request is correctly passed through the context
expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest);
expect(capturedQuotaState.proQuotaRequest).toEqual(mockRequest);
unmount();
});
@@ -3290,85 +3293,6 @@ describe('AppContainer State Management', () => {
unmount();
});
it('does not reset the hint timer when overflowingIdsSize decreases', async () => {
const { unmount } = await act(async () => renderAppContainer());
await waitFor(() => expect(capturedOverflowActions).toBeTruthy());
act(() => {
capturedOverflowActions.addOverflowingId('test-id-1');
capturedOverflowActions.addOverflowingId('test-id-2');
});
await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(true);
});
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
});
expect(capturedUIState.showIsExpandableHint).toBe(true);
act(() => {
capturedOverflowActions.removeOverflowingId('test-id-2');
});
act(() => {
vi.advanceTimersByTime(1);
});
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
});
await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(false);
});
unmount();
});
it('does not auto-reset the hint timer for new overflow while expanded', async () => {
const { stdin, unmount } = await act(async () => renderAppContainer());
await waitFor(() => expect(capturedOverflowActions).toBeTruthy());
act(() => {
capturedOverflowActions.addOverflowingId('test-id-1');
});
await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(true);
});
act(() => {
stdin.write('\x0f'); // Ctrl+O
});
expect(capturedUIState.constrainHeight).toBe(false);
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 1000);
});
expect(capturedUIState.showIsExpandableHint).toBe(true);
act(() => {
capturedOverflowActions.addOverflowingId('test-id-2');
});
act(() => {
vi.advanceTimersByTime(1);
});
act(() => {
vi.advanceTimersByTime(1000);
});
await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(false);
});
unmount();
});
it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => {
const { stdin, unmount } = await act(async () => renderAppContainer());
await waitFor(() => expect(capturedOverflowActions).toBeTruthy());
+58 -62
View File
@@ -25,6 +25,7 @@ import {
import { App } from './App.js';
import { AppContext } from './contexts/AppContext.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
import { QuotaContext } from './contexts/QuotaContext.js';
import {
UIActionsContext,
type UIActions,
@@ -187,7 +188,6 @@ import {
isToolAwaitingConfirmation,
getAllToolCalls,
} from './utils/historyUtils.js';
import { shouldAutoTriggerExpandHint } from './utils/expandHint.js';
interface AppContainerProps {
config: Config;
@@ -330,35 +330,24 @@ export const AppContainer = (props: AppContainerProps) => {
const showIsExpandableHint = Boolean(expandHintTrigger);
const overflowState = useOverflowState();
const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0;
const previousOverflowingIdsSizeRef = useRef(0);
const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight;
/**
* Manages the visibility and x-second timer for the expansion hint.
*
* This effect triggers the timer countdown whenever an overflow is detected
* while the response is actually constrained. The Ctrl+O handler still
* refreshes the hint manually when the user toggles expansion.
* or the user manually toggles the expansion state with Ctrl+O.
* By depending on overflowingIdsSize, the timer resets when *new* views
* overflow, but avoids infinitely resetting during single-view streaming.
*
* We only auto-refresh when the number of overflowing regions grows. That
* keeps the "show more" hint responsive for newly truncated content without
* retriggering on layout churn, overflow shrinkage, or while the content is
* already expanded.
* In alternate buffer mode, we don't trigger the hint automatically on overflow
* to avoid noise, but the user can still trigger it manually with Ctrl+O.
*/
useEffect(() => {
const previousOverflowingIdsSize = previousOverflowingIdsSizeRef.current;
if (
shouldAutoTriggerExpandHint({
constrainHeight,
overflowingIdsSize,
previousOverflowingIdsSize,
})
) {
if (hasOverflowState) {
triggerExpandHint(true);
}
previousOverflowingIdsSizeRef.current = overflowingIdsSize;
}, [constrainHeight, overflowingIdsSize, triggerExpandHint]);
}, [hasOverflowState, overflowingIdsSize, triggerExpandHint]);
const [defaultBannerText, setDefaultBannerText] = useState('');
const [warningBannerText, setWarningBannerText] = useState('');
@@ -2413,6 +2402,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
const quotaState = useMemo(
() => ({
userTier,
stats: quotaStats,
proQuotaRequest,
validationRequest,
// G1 AI Credits dialog state
overageMenuRequest,
emptyWalletRequest,
}),
[
userTier,
quotaStats,
proQuotaRequest,
validationRequest,
overageMenuRequest,
emptyWalletRequest,
],
);
const uiState: UIState = useMemo(
() => ({
history: historyManager.history,
@@ -2485,15 +2494,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
showApprovalModeIndicator,
allowPlanMode,
currentModel,
quota: {
userTier,
stats: quotaStats,
proQuotaRequest,
validationRequest,
// G1 AI Credits dialog state
overageMenuRequest,
emptyWalletRequest,
},
contextFileNames,
errorCount,
availableTerminalHeight,
@@ -2604,12 +2604,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
queueErrorMessage,
showApprovalModeIndicator,
allowPlanMode,
userTier,
quotaStats,
proQuotaRequest,
validationRequest,
overageMenuRequest,
emptyWalletRequest,
contextFileNames,
errorCount,
availableTerminalHeight,
@@ -2828,34 +2822,36 @@ Logging in with Google... Restarting Gemini CLI to continue.
return (
<UIStateContext.Provider value={uiState}>
<InputContext.Provider value={inputState}>
<UIActionsContext.Provider value={uiActions}>
<ConfigContext.Provider value={config}>
<AppContext.Provider
value={{
version: props.version,
startupWarnings: props.startupWarnings || [],
}}
>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
isExpanded={isExpanded}
toggleExpansion={toggleExpansion}
toggleAllExpansion={toggleAllExpansion}
<QuotaContext.Provider value={quotaState}>
<InputContext.Provider value={inputState}>
<UIActionsContext.Provider value={uiActions}>
<ConfigContext.Provider value={config}>
<AppContext.Provider
value={{
version: props.version,
startupWarnings: props.startupWarnings || [],
}}
>
<ShellFocusContext.Provider value={isFocused}>
<MouseProvider mouseEventsEnabled={mouseMode}>
<ScrollProvider>
<App key={`app-${forceRerenderKey}`} />
</ScrollProvider>
</MouseProvider>
</ShellFocusContext.Provider>
</ToolActionsProvider>
</AppContext.Provider>
</ConfigContext.Provider>
</UIActionsContext.Provider>
</InputContext.Provider>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
isExpanded={isExpanded}
toggleExpansion={toggleExpansion}
toggleAllExpansion={toggleAllExpansion}
>
<ShellFocusContext.Provider value={isFocused}>
<MouseProvider mouseEventsEnabled={mouseMode}>
<ScrollProvider>
<App key={`app-${forceRerenderKey}`} />
</ScrollProvider>
</MouseProvider>
</ShellFocusContext.Provider>
</ToolActionsProvider>
</AppContext.Provider>
</ConfigContext.Provider>
</UIActionsContext.Provider>
</InputContext.Provider>
</QuotaContext.Provider>
</UIStateContext.Provider>
);
};
@@ -11,9 +11,9 @@ import {
CoreToolCallStatus,
ApprovalMode,
makeFakeConfig,
type SerializableConfirmationDetails,
} from '@google/gemini-cli-core';
import { type UIState } from './contexts/UIStateContext.js';
import type { SerializableConfirmationDetails } from '@google/gemini-cli-core';
import { act } from 'react';
import { StreamingState } from './types.js';
@@ -107,15 +107,6 @@ describe('Full Terminal Tool Confirmation Snapshot', () => {
constrainHeight: true,
isConfigInitialized: true,
cleanUiDetailsVisible: true,
quota: {
userTier: 'PRO',
stats: {
limits: {},
usage: {},
},
proQuotaRequest: null,
validationRequest: null,
},
pendingHistoryItems: [
{
id: 2,
@@ -145,6 +136,13 @@ describe('Full Terminal Tool Confirmation Snapshot', () => {
const { waitUntilReady, lastFrame, generateSvg, unmount } =
await renderWithProviders(<App />, {
uiState: mockUIState,
quotaState: {
userTier: 'PRO',
stats: {
remaining: 100,
limit: 1000,
},
},
config: mockConfig,
settings: createMockSettings({
merged: {
@@ -201,12 +201,6 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
isBackgroundTaskVisible: false,
embeddedShellFocused: false,
showIsExpandableHint: false,
quota: {
userTier: undefined,
stats: undefined,
proQuotaRequest: null,
validationRequest: null,
},
...overrides,
}) as UIState;
@@ -245,6 +239,7 @@ const createMockConfig = (overrides = {}): Config =>
...overrides,
}) as unknown as Config;
import { QuotaContext, type QuotaState } from '../contexts/QuotaContext.js';
import { InputContext, type InputState } from '../contexts/InputContext.js';
const renderComposer = async (
@@ -253,6 +248,7 @@ const renderComposer = async (
config = createMockConfig(),
uiActions = createMockUIActions(),
inputStateOverrides: Partial<InputState> = {},
quotaStateOverrides: Partial<QuotaState> = {},
) => {
const inputState = {
buffer: { text: '' } as unknown as TextBuffer,
@@ -266,16 +262,28 @@ const renderComposer = async (
...inputStateOverrides,
};
const quotaState: QuotaState = {
userTier: undefined,
stats: undefined,
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
...quotaStateOverrides,
};
const result = await render(
<ConfigContext.Provider value={config as unknown as Config}>
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
<InputContext.Provider value={inputState}>
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<Composer isFocused={true} />
</UIActionsContext.Provider>
</UIStateContext.Provider>
</InputContext.Provider>
<QuotaContext.Provider value={quotaState}>
<InputContext.Provider value={inputState}>
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<Composer isFocused={true} />
</UIActionsContext.Provider>
</UIStateContext.Provider>
</InputContext.Provider>
</QuotaContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>,
);
@@ -9,6 +9,7 @@ import { DialogManager } from './DialogManager.js';
import { describe, it, expect, vi } from 'vitest';
import { Text } from 'ink';
import { type UIState } from '../contexts/UIStateContext.js';
import { type QuotaState } from '../contexts/QuotaContext.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import { type IdeInfo } from '@google/gemini-cli-core';
@@ -75,14 +76,6 @@ describe('DialogManager', () => {
terminalWidth: 80,
confirmUpdateExtensionRequests: [],
showIdeRestartPrompt: false,
quota: {
userTier: undefined,
stats: undefined,
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
shouldShowIdePrompt: false,
isFolderTrustDialogOpen: false,
loopDetectionConfirmationRequest: null,
@@ -112,7 +105,7 @@ describe('DialogManager', () => {
unmount();
});
const testCases: Array<[Partial<UIState>, string]> = [
const testCases: Array<[Partial<UIState>, string, Partial<QuotaState>?]> = [
[
{
showIdeRestartPrompt: true,
@@ -121,23 +114,17 @@ describe('DialogManager', () => {
'IdeTrustChangeDialog',
],
[
{},
'ProQuotaDialog',
{
quota: {
userTier: undefined,
stats: undefined,
proQuotaRequest: {
failedModel: 'a',
fallbackModel: 'b',
message: 'c',
isTerminalQuotaError: false,
resolve: vi.fn(),
},
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
proQuotaRequest: {
failedModel: 'a',
fallbackModel: 'b',
message: 'c',
isTerminalQuotaError: false,
resolve: vi.fn(),
},
},
'ProQuotaDialog',
],
[
{
@@ -195,7 +182,11 @@ describe('DialogManager', () => {
it.each(testCases)(
'renders %s when state is %o',
async (uiStateOverride, expectedComponent) => {
async (
uiStateOverride: Partial<UIState>,
expectedComponent: string,
quotaStateOverride?: Partial<QuotaState>,
) => {
const { lastFrame, unmount } = await renderWithProviders(
<DialogManager {...defaultProps} />,
{
@@ -203,6 +194,7 @@ describe('DialogManager', () => {
...baseUiState,
...uiStateOverride,
} as Partial<UIState> as UIState,
quotaState: quotaStateOverride,
},
);
expect(lastFrame()).toContain(expectedComponent);
@@ -27,6 +27,7 @@ import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'
import { ModelDialog } from './ModelDialog.js';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useQuotaState } from '../contexts/QuotaContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
@@ -52,6 +53,7 @@ export const DialogManager = ({
const settings = useSettings();
const uiState = useUIState();
const quotaState = useQuotaState();
const uiActions = useUIActions();
const {
constrainHeight,
@@ -74,54 +76,50 @@ export const DialogManager = ({
/>
);
}
if (uiState.quota.proQuotaRequest) {
if (quotaState.proQuotaRequest) {
return (
<ProQuotaDialog
failedModel={uiState.quota.proQuotaRequest.failedModel}
fallbackModel={uiState.quota.proQuotaRequest.fallbackModel}
message={uiState.quota.proQuotaRequest.message}
isTerminalQuotaError={
uiState.quota.proQuotaRequest.isTerminalQuotaError
}
isModelNotFoundError={
!!uiState.quota.proQuotaRequest.isModelNotFoundError
}
authType={uiState.quota.proQuotaRequest.authType}
failedModel={quotaState.proQuotaRequest.failedModel}
fallbackModel={quotaState.proQuotaRequest.fallbackModel}
message={quotaState.proQuotaRequest.message}
isTerminalQuotaError={quotaState.proQuotaRequest.isTerminalQuotaError}
isModelNotFoundError={!!quotaState.proQuotaRequest.isModelNotFoundError}
authType={quotaState.proQuotaRequest.authType}
tierName={config?.getUserTierName()}
onChoice={uiActions.handleProQuotaChoice}
/>
);
}
if (uiState.quota.validationRequest) {
if (quotaState.validationRequest) {
return (
<ValidationDialog
validationLink={uiState.quota.validationRequest.validationLink}
validationLink={quotaState.validationRequest.validationLink}
validationDescription={
uiState.quota.validationRequest.validationDescription
quotaState.validationRequest.validationDescription
}
learnMoreUrl={uiState.quota.validationRequest.learnMoreUrl}
learnMoreUrl={quotaState.validationRequest.learnMoreUrl}
onChoice={uiActions.handleValidationChoice}
/>
);
}
if (uiState.quota.overageMenuRequest) {
if (quotaState.overageMenuRequest) {
return (
<OverageMenuDialog
failedModel={uiState.quota.overageMenuRequest.failedModel}
fallbackModel={uiState.quota.overageMenuRequest.fallbackModel}
resetTime={uiState.quota.overageMenuRequest.resetTime}
creditBalance={uiState.quota.overageMenuRequest.creditBalance}
failedModel={quotaState.overageMenuRequest.failedModel}
fallbackModel={quotaState.overageMenuRequest.fallbackModel}
resetTime={quotaState.overageMenuRequest.resetTime}
creditBalance={quotaState.overageMenuRequest.creditBalance}
onChoice={uiActions.handleOverageMenuChoice}
/>
);
}
if (uiState.quota.emptyWalletRequest) {
if (quotaState.emptyWalletRequest) {
return (
<EmptyWalletDialog
failedModel={uiState.quota.emptyWalletRequest.failedModel}
fallbackModel={uiState.quota.emptyWalletRequest.fallbackModel}
resetTime={uiState.quota.emptyWalletRequest.resetTime}
onGetCredits={uiState.quota.emptyWalletRequest.onGetCredits}
failedModel={quotaState.emptyWalletRequest.failedModel}
fallbackModel={quotaState.emptyWalletRequest.fallbackModel}
resetTime={quotaState.emptyWalletRequest.resetTime}
onGetCredits={quotaState.emptyWalletRequest.onGetCredits}
onChoice={uiActions.handleEmptyWalletChoice}
/>
);
+20 -35
View File
@@ -267,21 +267,16 @@ describe('<Footer />', () => {
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 15,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
quotaState: {
stats: {
remaining: 15,
limit: 100,
resetTime: undefined,
},
},
});
expect(lastFrame()).toContain('85%');
expect(lastFrame()).toContain('85% used');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
@@ -292,21 +287,16 @@ describe('<Footer />', () => {
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 85,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
quotaState: {
stats: {
remaining: 85,
limit: 100,
resetTime: undefined,
},
},
});
expect(normalizeFrame(lastFrame())).not.toContain('used');
expect(normalizeFrame(lastFrame())).toContain('15% used');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
@@ -317,17 +307,12 @@ describe('<Footer />', () => {
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 0,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
quotaState: {
stats: {
remaining: 0,
limit: 100,
resetTime: undefined,
},
},
});
+5 -5
View File
@@ -23,6 +23,7 @@ import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { QuotaDisplay } from './QuotaDisplay.js';
import { DebugProfiler } from './DebugProfiler.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useQuotaState } from '../contexts/QuotaContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
@@ -174,6 +175,7 @@ interface FooterColumn {
export const Footer: React.FC = () => {
const uiState = useUIState();
const quotaState = useQuotaState();
const { copyModeEnabled } = useInputState();
const config = useConfig();
const settings = useSettings();
@@ -203,7 +205,6 @@ export const Footer: React.FC = () => {
promptTokenCount,
isTrustedFolder,
terminalWidth,
quotaStats,
} = {
model: uiState.currentModel,
targetDir: config.getTargetDir(),
@@ -216,9 +217,10 @@ export const Footer: React.FC = () => {
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
isTrustedFolder: uiState.isTrustedFolder,
terminalWidth: uiState.terminalWidth,
quotaStats: uiState.quota.stats,
};
const quotaStats = quotaState.stats;
const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
const showErrorSummary =
!showErrorDetails &&
@@ -351,13 +353,11 @@ export const Footer: React.FC = () => {
<QuotaDisplay
remaining={quotaStats.remaining}
limit={quotaStats.limit}
resetTime={quotaStats.resetTime}
terse={true}
forceShow={true}
lowercase={true}
/>
),
10, // "daily 100%" is 10 chars, but terse is "100%" (4 chars)
9, // "100% used" is 9 chars
);
}
break;
@@ -256,7 +256,7 @@ describe('<FooterConfigDialog />', () => {
expect(nextLine).toContain('·');
expect(nextLine).toContain('~/project/path');
expect(nextLine).toContain('docker');
expect(nextLine).toContain('97%');
expect(nextLine).toContain('42% used');
});
await expect(renderResult).toMatchSvgSnapshot();
@@ -242,7 +242,7 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
'context-used': (
<Text color={getColor('context-used', itemColor)}>85% used</Text>
),
quota: <Text color={getColor('quota', itemColor)}>97%</Text>,
quota: <Text color={getColor('quota', itemColor)}>42% used</Text>,
'memory-usage': (
<Text color={getColor('memory-usage', itemColor)}>260 MB</Text>
),
@@ -50,7 +50,6 @@ interface HistoryItemDisplayProps {
isFirstThinking?: boolean;
isFirstAfterThinking?: boolean;
isToolGroupBoundary?: boolean;
suppressNarration?: boolean;
}
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@@ -64,7 +63,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
isFirstThinking = false,
isFirstAfterThinking = false,
isToolGroupBoundary = false,
suppressNarration = false,
}) => {
const settings = useSettings();
const inlineThinkingMode = getInlineThinkingMode(settings);
@@ -75,17 +73,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
isToolGroupBoundary
);
// If there's a topic update in this turn, we suppress the regular narration
// and thoughts as they are being "replaced" by the update_topic tool.
if (
suppressNarration &&
(itemForDisplay.type === 'thinking' ||
itemForDisplay.type === 'gemini' ||
itemForDisplay.type === 'gemini_content')
) {
return null;
}
return (
<Box
flexDirection="column"
@@ -205,7 +192,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
borderTop={itemForDisplay.borderTop}
borderBottom={itemForDisplay.borderBottom}
isExpandable={isExpandable}
isToolGroupBoundary={isToolGroupBoundary}
/>
)}
{itemForDisplay.type === 'subagent' && (
@@ -1836,7 +1836,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
height={Math.min(buffer.viewportHeight, scrollableData.length)}
width="100%"
>
{isAlternateBuffer ? (
{config.getUseTerminalBuffer() ? (
<ScrollableList
ref={listRef}
hasFocus={focus}
+1 -40
View File
@@ -7,7 +7,6 @@
import { Box, Static } from 'ink';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
@@ -22,7 +21,6 @@ import { useMemo, memo, useCallback, useEffect, useRef } from 'react';
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { isTopicTool } from './messages/TopicMessage.js';
import { appEvents, AppEvent } from '../../utils/events.js';
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
@@ -82,35 +80,6 @@ export const MainContent = () => {
return -1;
}, [uiState.history]);
const settings = useSettings();
const topicUpdateNarrationEnabled =
settings.merged.experimental?.topicUpdateNarration === true;
const suppressNarrationFlags = useMemo(() => {
const combinedHistory = [...uiState.history, ...pendingHistoryItems];
const flags = new Array<boolean>(combinedHistory.length).fill(false);
if (topicUpdateNarrationEnabled) {
let toolGroupInTurn = false;
for (let i = combinedHistory.length - 1; i >= 0; i--) {
const item = combinedHistory[i];
if (item.type === 'user' || item.type === 'user_shell') {
toolGroupInTurn = false;
} else if (item.type === 'tool_group') {
toolGroupInTurn = item.tools.some((t) => isTopicTool(t.name));
} else if (
(item.type === 'thinking' ||
item.type === 'gemini' ||
item.type === 'gemini_content') &&
toolGroupInTurn
) {
flags[i] = true;
}
}
}
return flags;
}, [uiState.history, pendingHistoryItems, topicUpdateNarrationEnabled]);
const augmentedHistory = useMemo(
() =>
uiState.history.map((item, i) => {
@@ -129,10 +98,9 @@ export const MainContent = () => {
isFirstThinking,
isFirstAfterThinking,
isToolGroupBoundary,
suppressNarration: suppressNarrationFlags[i] ?? false,
};
}),
[uiState.history, lastUserPromptIndex, suppressNarrationFlags],
[uiState.history, lastUserPromptIndex],
);
const historyItems = useMemo(
@@ -144,7 +112,6 @@ export const MainContent = () => {
isFirstThinking,
isFirstAfterThinking,
isToolGroupBoundary,
suppressNarration,
}) => (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
@@ -162,7 +129,6 @@ export const MainContent = () => {
isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking}
isToolGroupBoundary={isToolGroupBoundary}
suppressNarration={suppressNarration}
/>
),
),
@@ -201,9 +167,6 @@ export const MainContent = () => {
(item.type !== 'tool_group' && prevType === 'tool_group') ||
(item.type === 'tool_group' && prevType !== 'tool_group');
const suppressNarration =
suppressNarrationFlags[uiState.history.length + i] ?? false;
return (
<HistoryItemDisplay
key={`pending-${i}`}
@@ -217,7 +180,6 @@ export const MainContent = () => {
isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking}
isToolGroupBoundary={isToolGroupBoundary}
suppressNarration={suppressNarration}
/>
);
})}
@@ -237,7 +199,6 @@ export const MainContent = () => {
showConfirmationQueue,
confirmingTool,
uiState.history,
suppressNarrationFlags,
],
);
@@ -5,12 +5,16 @@
*/
import { act } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Config, InboxSkill } from '@google/gemini-cli-core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Config, InboxSkill, InboxPatch } from '@google/gemini-cli-core';
import {
dismissInboxSkill,
listInboxSkills,
listInboxPatches,
moveInboxSkill,
applyInboxPatch,
dismissInboxPatch,
isProjectSkillPatchTarget,
} from '@google/gemini-cli-core';
import { waitFor } from '../../test-utils/async.js';
import { renderWithProviders } from '../../test-utils/render.js';
@@ -24,7 +28,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
...original,
dismissInboxSkill: vi.fn(),
listInboxSkills: vi.fn(),
listInboxPatches: vi.fn(),
moveInboxSkill: vi.fn(),
applyInboxPatch: vi.fn(),
dismissInboxPatch: vi.fn(),
isProjectSkillPatchTarget: vi.fn(),
getErrorMessage: vi.fn((error: unknown) =>
error instanceof Error ? error.message : String(error),
),
@@ -32,20 +40,108 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
});
const mockListInboxSkills = vi.mocked(listInboxSkills);
const mockListInboxPatches = vi.mocked(listInboxPatches);
const mockMoveInboxSkill = vi.mocked(moveInboxSkill);
const mockDismissInboxSkill = vi.mocked(dismissInboxSkill);
const mockApplyInboxPatch = vi.mocked(applyInboxPatch);
const mockDismissInboxPatch = vi.mocked(dismissInboxPatch);
const mockIsProjectSkillPatchTarget = vi.mocked(isProjectSkillPatchTarget);
const inboxSkill: InboxSkill = {
dirName: 'inbox-skill',
name: 'Inbox Skill',
description: 'A test skill',
content:
'---\nname: Inbox Skill\ndescription: A test skill\n---\n\n## Procedure\n1. Do the thing\n',
extractedAt: '2025-01-15T10:00:00Z',
};
const inboxPatch: InboxPatch = {
fileName: 'update-docs.patch',
name: 'update-docs',
entries: [
{
targetPath: '/home/user/.gemini/skills/docs-writer/SKILL.md',
diffContent: [
'--- /home/user/.gemini/skills/docs-writer/SKILL.md',
'+++ /home/user/.gemini/skills/docs-writer/SKILL.md',
'@@ -1,3 +1,4 @@',
' line1',
' line2',
'+line2.5',
' line3',
].join('\n'),
},
],
extractedAt: '2025-01-20T14:00:00Z',
};
const workspacePatch: InboxPatch = {
fileName: 'workspace-update.patch',
name: 'workspace-update',
entries: [
{
targetPath: '/repo/.gemini/skills/docs-writer/SKILL.md',
diffContent: [
'--- /repo/.gemini/skills/docs-writer/SKILL.md',
'+++ /repo/.gemini/skills/docs-writer/SKILL.md',
'@@ -1,1 +1,2 @@',
' line1',
'+line2',
].join('\n'),
},
],
};
const multiSectionPatch: InboxPatch = {
fileName: 'multi-section.patch',
name: 'multi-section',
entries: [
{
targetPath: '/home/user/.gemini/skills/docs-writer/SKILL.md',
diffContent: [
'--- /home/user/.gemini/skills/docs-writer/SKILL.md',
'+++ /home/user/.gemini/skills/docs-writer/SKILL.md',
'@@ -1,1 +1,2 @@',
' line1',
'+line2',
].join('\n'),
},
{
targetPath: '/home/user/.gemini/skills/docs-writer/SKILL.md',
diffContent: [
'--- /home/user/.gemini/skills/docs-writer/SKILL.md',
'+++ /home/user/.gemini/skills/docs-writer/SKILL.md',
'@@ -3,1 +4,2 @@',
' line3',
'+line4',
].join('\n'),
},
],
};
const windowsGlobalPatch: InboxPatch = {
fileName: 'windows-update.patch',
name: 'windows-update',
entries: [
{
targetPath: 'C:\\Users\\sandy\\.gemini\\skills\\docs-writer\\SKILL.md',
diffContent: [
'--- C:\\Users\\sandy\\.gemini\\skills\\docs-writer\\SKILL.md',
'+++ C:\\Users\\sandy\\.gemini\\skills\\docs-writer\\SKILL.md',
'@@ -1,1 +1,2 @@',
' line1',
'+line2',
].join('\n'),
},
],
};
describe('SkillInboxDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
mockListInboxSkills.mockResolvedValue([inboxSkill]);
mockListInboxPatches.mockResolvedValue([]);
mockMoveInboxSkill.mockResolvedValue({
success: true,
message: 'Moved "inbox-skill" to ~/.gemini/skills.',
@@ -54,6 +150,30 @@ describe('SkillInboxDialog', () => {
success: true,
message: 'Dismissed "inbox-skill" from inbox.',
});
mockApplyInboxPatch.mockResolvedValue({
success: true,
message: 'Applied patch to 1 file.',
});
mockDismissInboxPatch.mockResolvedValue({
success: true,
message: 'Dismissed "update-docs.patch" from inbox.',
});
mockIsProjectSkillPatchTarget.mockImplementation(
async (targetPath: string, config: Config) => {
const projectSkillsDir = config.storage
?.getProjectSkillsDir?.()
?.replaceAll('\\', '/')
?.replace(/\/+$/, '');
return projectSkillsDir
? targetPath.replaceAll('\\', '/').startsWith(projectSkillsDir)
: false;
},
);
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('disables the project destination when the workspace is untrusted', async () => {
@@ -75,6 +195,17 @@ describe('SkillInboxDialog', () => {
expect(lastFrame()).toContain('Inbox Skill');
});
// Select skill → lands on preview
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
expect(lastFrame()).toContain('Review new skill');
});
// Select "Move" → lands on destination chooser
await act(async () => {
stdin.write('\r');
await waitUntilReady();
@@ -86,22 +217,6 @@ describe('SkillInboxDialog', () => {
expect(frame).toContain('unavailable until this workspace is trusted');
});
await act(async () => {
stdin.write('\x1b[B');
await waitUntilReady();
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
expect(mockDismissInboxSkill).toHaveBeenCalledWith(config, 'inbox-skill');
});
expect(mockMoveInboxSkill).not.toHaveBeenCalled();
expect(onReloadSkills).not.toHaveBeenCalled();
unmount();
});
@@ -125,11 +240,19 @@ describe('SkillInboxDialog', () => {
expect(lastFrame()).toContain('Inbox Skill');
});
// Select skill → preview
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
// Select "Move" → destination chooser
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
// Select "Global" → triggers move
await act(async () => {
stdin.write('\r');
await waitUntilReady();
@@ -165,11 +288,19 @@ describe('SkillInboxDialog', () => {
expect(lastFrame()).toContain('Inbox Skill');
});
// Select skill → preview
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
// Select "Move" → destination chooser
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
// Select "Global" → triggers move
await act(async () => {
stdin.write('\r');
await waitUntilReady();
@@ -184,4 +315,346 @@ describe('SkillInboxDialog', () => {
unmount();
});
describe('patch support', () => {
it('shows patches alongside skills with section headers', async () => {
mockListInboxPatches.mockResolvedValue([inboxPatch]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
storage: {
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
},
} as unknown as Config;
const { lastFrame, unmount } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
),
);
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('New Skills');
expect(frame).toContain('Inbox Skill');
expect(frame).toContain('Skill Updates');
expect(frame).toContain('update-docs');
});
unmount();
});
it('shows diff preview when a patch is selected', async () => {
mockListInboxSkills.mockResolvedValue([]);
mockListInboxPatches.mockResolvedValue([inboxPatch]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
storage: {
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
},
} as unknown as Config;
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<SkillInboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
),
);
await waitFor(() => {
expect(lastFrame()).toContain('update-docs');
});
// Select the patch
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Review changes before applying');
expect(frame).toContain('Apply');
expect(frame).toContain('Dismiss');
});
unmount();
});
it('applies a patch when Apply is selected', async () => {
mockListInboxSkills.mockResolvedValue([]);
mockListInboxPatches.mockResolvedValue([inboxPatch]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
storage: {
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
},
} as unknown as Config;
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
const { stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={onReloadSkills}
/>,
),
);
await waitFor(() => {
expect(mockListInboxPatches).toHaveBeenCalled();
});
// Select the patch
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
// Select "Apply"
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
expect(mockApplyInboxPatch).toHaveBeenCalledWith(
config,
'update-docs.patch',
);
});
expect(onReloadSkills).toHaveBeenCalled();
unmount();
});
it('disables Apply for workspace patches in an untrusted workspace', async () => {
mockListInboxSkills.mockResolvedValue([]);
mockListInboxPatches.mockResolvedValue([workspacePatch]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(false),
storage: {
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
},
} as unknown as Config;
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<SkillInboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
),
);
await waitFor(() => {
expect(lastFrame()).toContain('workspace-update');
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Apply');
expect(frame).toContain(
'.gemini/skills — unavailable until this workspace is trusted',
);
});
expect(mockApplyInboxPatch).not.toHaveBeenCalled();
unmount();
});
it('uses canonical project-scope checks before enabling Apply', async () => {
mockListInboxSkills.mockResolvedValue([]);
mockListInboxPatches.mockResolvedValue([workspacePatch]);
mockIsProjectSkillPatchTarget.mockResolvedValue(true);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(false),
storage: {
getProjectSkillsDir: vi
.fn()
.mockReturnValue('/symlinked/workspace/.gemini/skills'),
},
} as unknown as Config;
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<SkillInboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
),
);
await waitFor(() => {
expect(lastFrame()).toContain('workspace-update');
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
expect(lastFrame()).toContain(
'.gemini/skills — unavailable until this workspace is trusted',
);
});
expect(mockIsProjectSkillPatchTarget).toHaveBeenCalledWith(
'/repo/.gemini/skills/docs-writer/SKILL.md',
config,
);
expect(mockApplyInboxPatch).not.toHaveBeenCalled();
unmount();
});
it('dismisses a patch when Dismiss is selected', async () => {
mockListInboxSkills.mockResolvedValue([]);
mockListInboxPatches.mockResolvedValue([inboxPatch]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
storage: {
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
},
} as unknown as Config;
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
const { stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={onReloadSkills}
/>,
),
);
await waitFor(() => {
expect(mockListInboxPatches).toHaveBeenCalled();
});
// Select the patch
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
// Move down to "Dismiss" and select
await act(async () => {
stdin.write('\x1b[B');
await waitUntilReady();
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
expect(mockDismissInboxPatch).toHaveBeenCalledWith(
config,
'update-docs.patch',
);
});
expect(onReloadSkills).not.toHaveBeenCalled();
unmount();
});
it('shows Windows patch entries with a basename and origin tag', async () => {
vi.stubEnv('USERPROFILE', 'C:\\Users\\sandy');
mockListInboxSkills.mockResolvedValue([]);
mockListInboxPatches.mockResolvedValue([windowsGlobalPatch]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
storage: {
getProjectSkillsDir: vi
.fn()
.mockReturnValue('C:\\repo\\.gemini\\skills'),
},
} as unknown as Config;
const { lastFrame, unmount } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
),
);
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('[Global]');
expect(frame).toContain('SKILL.md');
expect(frame).not.toContain('C:\\Users\\sandy\\.gemini\\skills');
});
unmount();
});
it('renders multi-section patches without duplicate React keys', async () => {
mockListInboxSkills.mockResolvedValue([]);
mockListInboxPatches.mockResolvedValue([multiSectionPatch]);
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
storage: {
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
},
} as unknown as Config;
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<SkillInboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
),
);
await waitFor(() => {
expect(lastFrame()).toContain('multi-section');
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
expect(lastFrame()).toContain('Review changes before applying');
});
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
expect.stringContaining('Encountered two children with the same key'),
);
consoleErrorSpy.mockRestore();
unmount();
});
});
});
@@ -4,9 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import type React from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { Box, Text } from 'ink';
import { Box, Text, useStdout } from 'ink';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { Command } from '../key/keyMatchers.js';
@@ -14,25 +15,42 @@ import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
import { BaseSelectionList } from './shared/BaseSelectionList.js';
import type { SelectionListItem } from '../hooks/useSelectionList.js';
import { DialogFooter } from './shared/DialogFooter.js';
import { DiffRenderer } from './messages/DiffRenderer.js';
import {
type Config,
type InboxSkill,
type InboxPatch,
type InboxSkillDestination,
getErrorMessage,
listInboxSkills,
listInboxPatches,
moveInboxSkill,
dismissInboxSkill,
applyInboxPatch,
dismissInboxPatch,
isProjectSkillPatchTarget,
} from '@google/gemini-cli-core';
type Phase = 'list' | 'action';
type Phase = 'list' | 'skill-preview' | 'skill-action' | 'patch-preview';
type InboxItem =
| { type: 'skill'; skill: InboxSkill }
| { type: 'patch'; patch: InboxPatch; targetsProjectSkills: boolean }
| { type: 'header'; label: string };
interface DestinationChoice {
destination: InboxSkillDestination | 'dismiss';
destination: InboxSkillDestination;
label: string;
description: string;
}
const DESTINATION_CHOICES: DestinationChoice[] = [
interface PatchAction {
action: 'apply' | 'dismiss';
label: string;
description: string;
}
const SKILL_DESTINATION_CHOICES: DestinationChoice[] = [
{
destination: 'global',
label: 'Global',
@@ -43,13 +61,105 @@ const DESTINATION_CHOICES: DestinationChoice[] = [
label: 'Project',
description: '.gemini/skills — available in this workspace',
},
];
interface SkillPreviewAction {
action: 'move' | 'dismiss';
label: string;
description: string;
}
const SKILL_PREVIEW_CHOICES: SkillPreviewAction[] = [
{
destination: 'dismiss',
action: 'move',
label: 'Move',
description: 'Choose where to install this skill',
},
{
action: 'dismiss',
label: 'Dismiss',
description: 'Delete from inbox',
},
];
const PATCH_ACTION_CHOICES: PatchAction[] = [
{
action: 'apply',
label: 'Apply',
description: 'Apply patch and delete from inbox',
},
{
action: 'dismiss',
label: 'Dismiss',
description: 'Delete from inbox without applying',
},
];
function normalizePathForUi(filePath: string): string {
return path.posix.normalize(filePath.replaceAll('\\', '/'));
}
function getPathBasename(filePath: string): string {
const normalizedPath = normalizePathForUi(filePath);
const basename = path.posix.basename(normalizedPath);
return basename === '.' ? filePath : basename;
}
async function patchTargetsProjectSkills(
patch: InboxPatch,
config: Config,
): Promise<boolean> {
const entryTargetsProjectSkills = await Promise.all(
patch.entries.map((entry) =>
isProjectSkillPatchTarget(entry.targetPath, config),
),
);
return entryTargetsProjectSkills.some(Boolean);
}
/**
* Derives a bracketed origin tag from a skill file path,
* matching the existing [Built-in] convention in SkillsList.
*/
function getSkillOriginTag(filePath: string): string {
const normalizedPath = normalizePathForUi(filePath);
if (normalizedPath.includes('/bundle/')) {
return 'Built-in';
}
if (normalizedPath.includes('/extensions/')) {
return 'Extension';
}
if (normalizedPath.includes('/.gemini/skills/')) {
const homeDirs = [process.env['HOME'], process.env['USERPROFILE']]
.filter((homeDir): homeDir is string => Boolean(homeDir))
.map(normalizePathForUi);
if (
homeDirs.some((homeDir) =>
normalizedPath.startsWith(`${homeDir}/.gemini/skills/`),
)
) {
return 'Global';
}
return 'Workspace';
}
return '';
}
/**
* Creates a unified diff string representing a new file.
*/
function newFileDiff(filename: string, content: string): string {
const lines = content.split('\n');
const hunkLines = lines.map((l) => `+${l}`).join('\n');
return [
`--- /dev/null`,
`+++ ${filename}`,
`@@ -0,0 +1,${lines.length} @@`,
hunkLines,
].join('\n');
}
function formatDate(isoString: string): string {
try {
const date = new Date(isoString);
@@ -75,29 +185,57 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
onReloadSkills,
}) => {
const keyMatchers = useKeyMatchers();
const { stdout } = useStdout();
const terminalWidth = stdout?.columns ?? 80;
const isTrustedFolder = config.isTrustedFolder();
const [phase, setPhase] = useState<Phase>('list');
const [skills, setSkills] = useState<InboxSkill[]>([]);
const [items, setItems] = useState<InboxItem[]>([]);
const [loading, setLoading] = useState(true);
const [selectedSkill, setSelectedSkill] = useState<InboxSkill | null>(null);
const [selectedItem, setSelectedItem] = useState<InboxItem | null>(null);
const [feedback, setFeedback] = useState<{
text: string;
isError: boolean;
} | null>(null);
// Load inbox skills on mount
// Load inbox skills and patches on mount
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const result = await listInboxSkills(config);
const [skills, patches] = await Promise.all([
listInboxSkills(config),
listInboxPatches(config),
]);
const patchItems = await Promise.all(
patches.map(async (patch): Promise<InboxItem> => {
let targetsProjectSkills = false;
try {
targetsProjectSkills = await patchTargetsProjectSkills(
patch,
config,
);
} catch {
targetsProjectSkills = false;
}
return {
type: 'patch',
patch,
targetsProjectSkills,
};
}),
);
if (!cancelled) {
setSkills(result);
const combined: InboxItem[] = [
...skills.map((skill): InboxItem => ({ type: 'skill', skill })),
...patchItems,
];
setItems(combined);
setLoading(false);
}
} catch {
if (!cancelled) {
setSkills([]);
setItems([]);
setLoading(false);
}
}
@@ -107,18 +245,56 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
};
}, [config]);
const skillItems: Array<SelectionListItem<InboxSkill>> = useMemo(
() =>
skills.map((skill) => ({
key: skill.dirName,
value: skill,
})),
[skills],
const getItemKey = useCallback(
(item: InboxItem): string =>
item.type === 'skill'
? `skill:${item.skill.dirName}`
: item.type === 'patch'
? `patch:${item.patch.fileName}`
: `header:${item.label}`,
[],
);
const listItems: Array<SelectionListItem<InboxItem>> = useMemo(() => {
const skills = items.filter((i) => i.type === 'skill');
const patches = items.filter((i) => i.type === 'patch');
const result: Array<SelectionListItem<InboxItem>> = [];
// Only show section headers when both types are present
const showHeaders = skills.length > 0 && patches.length > 0;
if (showHeaders) {
const header: InboxItem = { type: 'header', label: 'New Skills' };
result.push({
key: 'header:new-skills',
value: header,
disabled: true,
hideNumber: true,
});
}
for (const item of skills) {
result.push({ key: getItemKey(item), value: item });
}
if (showHeaders) {
const header: InboxItem = { type: 'header', label: 'Skill Updates' };
result.push({
key: 'header:skill-updates',
value: header,
disabled: true,
hideNumber: true,
});
}
for (const item of patches) {
result.push({ key: getItemKey(item), value: item });
}
return result;
}, [items, getItemKey]);
const destinationItems: Array<SelectionListItem<DestinationChoice>> = useMemo(
() =>
DESTINATION_CHOICES.map((choice) => {
SKILL_DESTINATION_CHOICES.map((choice) => {
if (choice.destination === 'project' && !isTrustedFolder) {
return {
key: choice.destination,
@@ -139,15 +315,103 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
[isTrustedFolder],
);
const handleSelectSkill = useCallback((skill: InboxSkill) => {
setSelectedSkill(skill);
const selectedPatchTargetsProjectSkills = useMemo(() => {
if (!selectedItem || selectedItem.type !== 'patch') {
return false;
}
return selectedItem.targetsProjectSkills;
}, [selectedItem]);
const patchActionItems: Array<SelectionListItem<PatchAction>> = useMemo(
() =>
PATCH_ACTION_CHOICES.map((choice) => {
if (
choice.action === 'apply' &&
selectedPatchTargetsProjectSkills &&
!isTrustedFolder
) {
return {
key: choice.action,
value: {
...choice,
description:
'.gemini/skills — unavailable until this workspace is trusted',
},
disabled: true,
};
}
return {
key: choice.action,
value: choice,
};
}),
[isTrustedFolder, selectedPatchTargetsProjectSkills],
);
const skillPreviewItems: Array<SelectionListItem<SkillPreviewAction>> =
useMemo(
() =>
SKILL_PREVIEW_CHOICES.map((choice) => ({
key: choice.action,
value: choice,
})),
[],
);
const handleSelectItem = useCallback((item: InboxItem) => {
setSelectedItem(item);
setFeedback(null);
setPhase('action');
setPhase(item.type === 'skill' ? 'skill-preview' : 'patch-preview');
}, []);
const removeItem = useCallback(
(item: InboxItem) => {
setItems((prev) =>
prev.filter((i) => getItemKey(i) !== getItemKey(item)),
);
},
[getItemKey],
);
const handleSkillPreviewAction = useCallback(
(choice: SkillPreviewAction) => {
if (!selectedItem || selectedItem.type !== 'skill') return;
if (choice.action === 'move') {
setFeedback(null);
setPhase('skill-action');
return;
}
// Dismiss
setFeedback(null);
const skill = selectedItem.skill;
void (async () => {
try {
const result = await dismissInboxSkill(config, skill.dirName);
setFeedback({ text: result.message, isError: !result.success });
if (result.success) {
removeItem(selectedItem);
setSelectedItem(null);
setPhase('list');
}
} catch (error) {
setFeedback({
text: `Failed to dismiss skill: ${getErrorMessage(error)}`,
isError: true,
});
}
})();
},
[config, selectedItem, removeItem],
);
const handleSelectDestination = useCallback(
(choice: DestinationChoice) => {
if (!selectedSkill) return;
if (!selectedItem || selectedItem.type !== 'skill') return;
const skill = selectedItem.skill;
if (choice.destination === 'project' && !config.isTrustedFolder()) {
setFeedback({
@@ -161,16 +425,11 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
void (async () => {
try {
let result: { success: boolean; message: string };
if (choice.destination === 'dismiss') {
result = await dismissInboxSkill(config, selectedSkill.dirName);
} else {
result = await moveInboxSkill(
config,
selectedSkill.dirName,
choice.destination,
);
}
const result = await moveInboxSkill(
config,
skill.dirName,
choice.destination,
);
setFeedback({ text: result.message, isError: !result.success });
@@ -178,17 +437,10 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
return;
}
// Remove the skill from the local list.
setSkills((prev) =>
prev.filter((skill) => skill.dirName !== selectedSkill.dirName),
);
setSelectedSkill(null);
removeItem(selectedItem);
setSelectedItem(null);
setPhase('list');
if (choice.destination === 'dismiss') {
return;
}
try {
await onReloadSkills();
} catch (error) {
@@ -197,11 +449,68 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
isError: true,
});
}
} catch (error) {
setFeedback({
text: `Failed to install skill: ${getErrorMessage(error)}`,
isError: true,
});
}
})();
},
[config, selectedItem, onReloadSkills, removeItem],
);
const handleSelectPatchAction = useCallback(
(choice: PatchAction) => {
if (!selectedItem || selectedItem.type !== 'patch') return;
const patch = selectedItem.patch;
if (
choice.action === 'apply' &&
!config.isTrustedFolder() &&
selectedItem.targetsProjectSkills
) {
setFeedback({
text: 'Project skill patches are unavailable until this workspace is trusted.',
isError: true,
});
return;
}
setFeedback(null);
void (async () => {
try {
let result: { success: boolean; message: string };
if (choice.action === 'apply') {
result = await applyInboxPatch(config, patch.fileName);
} else {
result = await dismissInboxPatch(config, patch.fileName);
}
setFeedback({ text: result.message, isError: !result.success });
if (!result.success) {
return;
}
removeItem(selectedItem);
setSelectedItem(null);
setPhase('list');
if (choice.action === 'apply') {
try {
await onReloadSkills();
} catch (error) {
setFeedback({
text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
isError: true,
});
}
}
} catch (error) {
const operation =
choice.destination === 'dismiss'
? 'dismiss skill'
: 'install skill';
choice.action === 'apply' ? 'apply patch' : 'dismiss patch';
setFeedback({
text: `Failed to ${operation}: ${getErrorMessage(error)}`,
isError: true,
@@ -209,15 +518,18 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
}
})();
},
[config, selectedSkill, onReloadSkills],
[config, selectedItem, onReloadSkills, removeItem],
);
useKeypress(
(key) => {
if (keyMatchers[Command.ESCAPE](key)) {
if (phase === 'action') {
if (phase === 'skill-action') {
setPhase('skill-preview');
setFeedback(null);
} else if (phase !== 'list') {
setPhase('list');
setSelectedSkill(null);
setSelectedItem(null);
setFeedback(null);
} else {
onClose();
@@ -243,7 +555,7 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
);
}
if (skills.length === 0 && !feedback) {
if (items.length === 0 && !feedback) {
return (
<Box
flexDirection="column"
@@ -252,17 +564,18 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
paddingX={2}
paddingY={1}
>
<Text bold>Skill Inbox</Text>
<Text bold>Memory Inbox</Text>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
No extracted skills in inbox.
</Text>
<Text color={theme.text.secondary}>No items in inbox.</Text>
</Box>
<DialogFooter primaryAction="Esc to close" cancelAction="" />
</Box>
);
}
// Border + paddingX account for 6 chars of width
const contentWidth = terminalWidth - 6;
return (
<Box
flexDirection="column"
@@ -272,41 +585,87 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
paddingY={1}
width="100%"
>
{phase === 'list' ? (
{phase === 'list' && (
<>
<Text bold>
Skill Inbox ({skills.length} skill{skills.length !== 1 ? 's' : ''})
Memory Inbox ({items.length} item{items.length !== 1 ? 's' : ''})
</Text>
<Text color={theme.text.secondary}>
Skills extracted from past sessions. Select one to move or dismiss.
Extracted from past sessions. Select one to review.
</Text>
<Box flexDirection="column" marginTop={1}>
<BaseSelectionList<InboxSkill>
items={skillItems}
onSelect={handleSelectSkill}
<BaseSelectionList<InboxItem>
items={listItems}
onSelect={handleSelectItem}
isFocused={true}
showNumbers={true}
showNumbers={false}
showScrollArrows={true}
maxItemsToShow={8}
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" minHeight={2}>
<Text color={titleColor} bold>
{item.value.name}
</Text>
<Box flexDirection="row">
<Text color={theme.text.secondary} wrap="wrap">
{item.value.description}
</Text>
{item.value.extractedAt && (
<Text color={theme.text.secondary}>
{' · '}
{formatDate(item.value.extractedAt)}
renderItem={(item, { titleColor }) => {
if (item.value.type === 'header') {
return (
<Box marginTop={1}>
<Text color={theme.text.secondary} bold>
{item.value.label}
</Text>
)}
</Box>
);
}
if (item.value.type === 'skill') {
const skill = item.value.skill;
return (
<Box flexDirection="column" minHeight={2}>
<Text color={titleColor} bold>
{skill.name}
</Text>
<Box flexDirection="row">
<Text color={theme.text.secondary} wrap="wrap">
{skill.description}
</Text>
{skill.extractedAt && (
<Text color={theme.text.secondary}>
{' · '}
{formatDate(skill.extractedAt)}
</Text>
)}
</Box>
</Box>
);
}
const patch = item.value.patch;
const fileNames = patch.entries.map((e) =>
getPathBasename(e.targetPath),
);
const origin = getSkillOriginTag(
patch.entries[0]?.targetPath ?? '',
);
return (
<Box flexDirection="column" minHeight={2}>
<Box flexDirection="row">
<Text color={titleColor} bold>
{patch.name}
</Text>
{origin && (
<Text color={theme.text.secondary}>
{` [${origin}]`}
</Text>
)}
</Box>
<Box flexDirection="row">
<Text color={theme.text.secondary}>
{fileNames.join(', ')}
</Text>
{patch.extractedAt && (
<Text color={theme.text.secondary}>
{' · '}
{formatDate(patch.extractedAt)}
</Text>
)}
</Box>
</Box>
</Box>
)}
);
}}
/>
</Box>
@@ -328,9 +687,73 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
cancelAction="Esc to close"
/>
</>
) : (
)}
{phase === 'skill-preview' && selectedItem?.type === 'skill' && (
<>
<Text bold>Move &quot;{selectedSkill?.name}&quot;</Text>
<Text bold>{selectedItem.skill.name}</Text>
<Text color={theme.text.secondary}>
Review new skill before installing.
</Text>
{selectedItem.skill.content && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.secondary} bold>
SKILL.md
</Text>
<DiffRenderer
diffContent={newFileDiff(
'SKILL.md',
selectedItem.skill.content,
)}
filename="SKILL.md"
terminalWidth={contentWidth}
/>
</Box>
)}
<Box flexDirection="column" marginTop={1}>
<BaseSelectionList<SkillPreviewAction>
items={skillPreviewItems}
onSelect={handleSkillPreviewAction}
isFocused={true}
showNumbers={true}
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" minHeight={2}>
<Text color={titleColor} bold>
{item.value.label}
</Text>
<Text color={theme.text.secondary}>
{item.value.description}
</Text>
</Box>
)}
/>
</Box>
{feedback && (
<Box marginTop={1}>
<Text
color={
feedback.isError ? theme.status.error : theme.status.success
}
>
{feedback.isError ? '✗ ' : '✓ '}
{feedback.text}
</Text>
</Box>
)}
<DialogFooter
primaryAction="Enter to confirm"
cancelAction="Esc to go back"
/>
</>
)}
{phase === 'skill-action' && selectedItem?.type === 'skill' && (
<>
<Text bold>Move &quot;{selectedItem.skill.name}&quot;</Text>
<Text color={theme.text.secondary}>
Choose where to install this skill.
</Text>
@@ -373,6 +796,81 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
/>
</>
)}
{phase === 'patch-preview' && selectedItem?.type === 'patch' && (
<>
<Text bold>{selectedItem.patch.name}</Text>
<Box flexDirection="row">
<Text color={theme.text.secondary}>
Review changes before applying.
</Text>
{(() => {
const origin = getSkillOriginTag(
selectedItem.patch.entries[0]?.targetPath ?? '',
);
return origin ? (
<Text color={theme.text.secondary}>{` [${origin}]`}</Text>
) : null;
})()}
</Box>
<Box flexDirection="column" marginTop={1}>
{selectedItem.patch.entries.map((entry, index) => (
<Box
key={`${selectedItem.patch.fileName}:${entry.targetPath}:${index}`}
flexDirection="column"
marginBottom={1}
>
<Text color={theme.text.secondary} bold>
{entry.targetPath}
</Text>
<DiffRenderer
diffContent={entry.diffContent}
filename={entry.targetPath}
terminalWidth={contentWidth}
/>
</Box>
))}
</Box>
<Box flexDirection="column" marginTop={1}>
<BaseSelectionList<PatchAction>
items={patchActionItems}
onSelect={handleSelectPatchAction}
isFocused={true}
showNumbers={true}
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" minHeight={2}>
<Text color={titleColor} bold>
{item.value.label}
</Text>
<Text color={theme.text.secondary}>
{item.value.description}
</Text>
</Box>
)}
/>
</Box>
{feedback && (
<Box marginTop={1}>
<Text
color={
feedback.isError ? theme.status.error : theme.status.success
}
>
{feedback.isError ? '✗ ' : '✓ '}
{feedback.text}
</Text>
</Box>
)}
<DialogFooter
primaryAction="Enter to confirm"
cancelAction="Esc to go back"
/>
</>
)}
</Box>
);
};
@@ -1,14 +1,14 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] = `
" workspace (/directory) sandbox /model /stats
" workspace (/directory) sandbox /model quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro limit reached
"
`;
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85%
" workspace (/directory) sandbox /model quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85% used
"
`;
@@ -39,7 +39,7 @@ exports[`<Footer /> > footer configuration filtering (golden snapshots) > render
`;
exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = `
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15%
" workspace (/directory) sandbox /model quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15% used
"
`;
@@ -50,7 +50,7 @@
<text x="72" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> quota</text>
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Remaining usage on daily limit (not shown when unavailable)</text>
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Percentage of daily limit used (not shown when unavailable)</text>
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="274" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
@@ -132,10 +132,10 @@
<text x="0" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="631" fill="#afafaf" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
<text x="297" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="405" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="513" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="693" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/stats</text>
<text x="288" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="396" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="504" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="684" y="631" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">quota</text>
<rect x="801" y="629" width="36" height="17" fill="#001a00" />
<text x="801" y="631" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">diff</text>
<rect x="837" y="629" width="18" height="17" fill="#001a00" />
@@ -144,10 +144,10 @@
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<text x="297" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="405" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="513" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="693" y="648" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
<text x="288" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="396" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="504" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="684" y="648" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">42% used</text>
<rect x="801" y="646" width="27" height="17" fill="#001a00" />
<text x="801" y="648" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">+12</text>
<rect x="828" y="646" width="9" height="17" fill="#001a00" />

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

@@ -59,7 +59,7 @@
<text x="72" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> quota</text>
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Remaining usage on daily limit (not shown when unavailable)</text>
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Percentage of daily limit used (not shown when unavailable)</text>
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="274" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
@@ -133,10 +133,10 @@
<text x="27" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="45" y="629" width="198" height="17" fill="#001a00" />
<text x="45" y="631" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
<text x="324" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="459" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="594" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="801" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/stats</text>
<text x="315" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="450" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="585" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="783" y="631" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">quota</text>
<text x="864" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
@@ -144,10 +144,10 @@
<rect x="45" y="646" width="126" height="17" fill="#001a00" />
<text x="45" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<rect x="171" y="646" width="72" height="17" fill="#001a00" />
<text x="324" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="459" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="594" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="801" y="648" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
<text x="315" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="450" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="585" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="783" y="648" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">42% used</text>
<text x="864" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@@ -50,7 +50,7 @@
<text x="72" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> quota</text>
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Remaining usage on daily limit (not shown when unavailable)</text>
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Percentage of daily limit used (not shown when unavailable)</text>
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="274" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
@@ -131,13 +131,13 @@
<text x="27" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="631" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<text x="207" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="279" y="631" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="351" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="432" y="631" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="522" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="594" y="631" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="756" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="828" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
<text x="270" y="631" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="342" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="405" y="631" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="495" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="558" y="631" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="720" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="783" y="631" fill="#afafaf" textLength="72" lengthAdjust="spacingAndGlyphs">42% used</text>
<text x="864" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@@ -16,7 +16,7 @@ exports[`<FooterConfigDialog /> > highlights the active item in the preview 1`]
│ [✓] model-name │
│ Current model identifier │
│ [✓] quota │
Remaining usage on daily limit (not shown when unavailable) │
Percentage of daily limit used (not shown when unavailable) │
│ [ ] context-used │
│ Percentage of context window used │
│ [ ] memory-usage │
@@ -38,8 +38,8 @@ exports[`<FooterConfigDialog /> > highlights the active item in the preview 1`]
│ │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Preview: │ │
│ │ workspace (/directory) branch sandbox /model /stats diff │ │
│ │ ~/project/path main docker gemini-2.5-pro 97% +12 -4 │ │
│ │ workspace (/directory) branch sandbox /model quota diff │ │
│ │ ~/project/path main docker gemini-2.5-pro 42% used +12 -4 │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
@@ -61,7 +61,7 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] =
│ [✓] model-name │
│ Current model identifier │
│ [✓] quota │
Remaining usage on daily limit (not shown when unavailable) │
Percentage of daily limit used (not shown when unavailable) │
│ [ ] context-used │
│ Percentage of context window used │
│ [ ] memory-usage │
@@ -83,8 +83,8 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] =
│ │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Preview: │ │
│ │ workspace (/directory) branch sandbox /model /stats │ │
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
│ │ workspace (/directory) branch sandbox /model quota │ │
│ │ ~/project/path main docker gemini-2.5-pro 42% used │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -107,7 +107,7 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 2`] =
│ [✓] model-name │
│ Current model identifier │
│ [✓] quota │
Remaining usage on daily limit (not shown when unavailable) │
Percentage of daily limit used (not shown when unavailable) │
│ [ ] context-used │
│ Percentage of context window used │
│ [ ] memory-usage │
@@ -129,8 +129,8 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 2`] =
│ │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Preview: │ │
│ │ workspace (/directory) branch sandbox /model /stats │ │
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
│ │ workspace (/directory) branch sandbox /model quota │ │
│ │ ~/project/path main docker gemini-2.5-pro 42% used │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
@@ -152,7 +152,7 @@ exports[`<FooterConfigDialog /> > updates the preview when Show footer labels is
│ [✓] model-name │
│ Current model identifier │
│ [✓] quota │
Remaining usage on daily limit (not shown when unavailable) │
Percentage of daily limit used (not shown when unavailable) │
│ [ ] context-used │
│ Percentage of context window used │
│ [ ] memory-usage │
@@ -174,7 +174,7 @@ exports[`<FooterConfigDialog /> > updates the preview when Show footer labels is
│ │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Preview: │ │
│ │ ~/project/path · main · docker · gemini-2.5-pro · 97% │ │
│ │ ~/project/path · main · docker · gemini-2.5-pro · 42% used │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
@@ -102,7 +102,6 @@ interface ToolGroupMessageProps {
borderTop?: boolean;
borderBottom?: boolean;
isExpandable?: boolean;
isToolGroupBoundary?: boolean;
}
// Main component renders the border and maps the tools using ToolMessage
@@ -116,7 +115,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
borderTop: borderTopOverride,
borderBottom: borderBottomOverride,
isExpandable,
isToolGroupBoundary,
}) => {
const settings = useSettings();
const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
@@ -248,11 +246,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
(showClosingBorder ? 1 : 0);
} else if (isTopicToolCall) {
// Topic Message Spacing Breakdown:
// 1. Top Margin (1): Present unless it's the very first item following a boundary.
// 1. Top Margin (1): Always present for spacing.
// 2. Topic Content (1).
// 3. Bottom Margin (1): Always present around TopicMessage for breathing room.
const hasTopMargin = !(isFirst && isToolGroupBoundary);
height += (hasTopMargin ? 1 : 0) + 1 + 1;
// 4. Closing Border (1): Added if transition logic (showClosingBorder) requires it.
height += 1 + 1 + 1 + (showClosingBorder ? 1 : 0);
} else if (isCompact) {
// Compact Tool: Always renders as a single dense line.
height += 1;
@@ -273,12 +271,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
}
}
return height;
}, [
groupedTools,
isCompactModeEnabled,
borderTopOverride,
isToolGroupBoundary,
]);
}, [groupedTools, isCompactModeEnabled, borderTopOverride]);
let countToolCallsWithResults = 0;
for (const tool of visibleToolCalls) {
@@ -446,10 +439,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
{isCompact ? (
<DenseToolMessage {...commonProps} />
) : isTopicToolCall ? (
<Box
marginTop={isFirst && isToolGroupBoundary ? 0 : 1}
marginBottom={1}
>
<Box marginTop={1} marginBottom={1}>
<TopicMessage {...commonProps} />
</Box>
) : isShellToolCall ? (
@@ -0,0 +1,34 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext } from 'react';
import type { QuotaStats } from '../types.js';
import type { UserTierId } from '@google/gemini-cli-core';
import type {
ProQuotaDialogRequest,
ValidationDialogRequest,
OverageMenuDialogRequest,
EmptyWalletDialogRequest,
} from './UIStateContext.js';
export interface QuotaState {
userTier?: UserTierId;
stats?: QuotaStats;
proQuotaRequest?: ProQuotaDialogRequest | null;
validationRequest?: ValidationDialogRequest | null;
overageMenuRequest?: OverageMenuDialogRequest | null;
emptyWalletRequest?: EmptyWalletDialogRequest | null;
}
export const QuotaContext = createContext<QuotaState | null>(null);
export const useQuotaState = () => {
const context = useContext(QuotaContext);
if (!context) {
throw new Error('useQuotaState must be used within a QuotaProvider');
}
return context;
};
+90 -20
View File
@@ -41,6 +41,24 @@ interface ScrollContextType {
const ScrollContext = createContext<ScrollContextType | null>(null);
/**
* The minimum fractional scroll delta to track.
*/
const SCROLL_STATIC_FRICTION = 0.001;
/**
* Calculates a scroll top value clamped between 0 and the maximum possible
* scroll position for the given container dimensions.
*/
const getClampedScrollTop = (
scrollTop: number,
scrollHeight: number,
innerHeight: number,
) => {
const maxScroll = Math.max(0, scrollHeight - innerHeight);
return Math.max(0, Math.min(scrollTop, maxScroll));
};
const findScrollableCandidates = (
mouseEvent: MouseEvent,
scrollables: Map<string, ScrollableEntry>,
@@ -90,6 +108,8 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
next.delete(id);
return next;
});
trueScrollRef.current.delete(id);
pendingFlushRef.current.delete(id);
}, []);
const scrollablesRef = useRef(scrollables);
@@ -97,7 +117,10 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
scrollablesRef.current = scrollables;
}, [scrollables]);
const pendingScrollsRef = useRef(new Map<string, number>());
const trueScrollRef = useRef(
new Map<string, { floatValue: number; expectedScrollTop: number }>(),
);
const pendingFlushRef = useRef(new Set<string>());
const flushScheduledRef = useRef(false);
const dragStateRef = useRef<{
@@ -115,13 +138,45 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
flushScheduledRef.current = true;
setTimeout(() => {
flushScheduledRef.current = false;
for (const [id, delta] of pendingScrollsRef.current.entries()) {
const ids = Array.from(pendingFlushRef.current);
pendingFlushRef.current.clear();
for (const id of ids) {
const entry = scrollablesRef.current.get(id);
if (entry) {
entry.scrollBy(delta);
const trueScroll = trueScrollRef.current.get(id);
if (entry && trueScroll) {
const { scrollTop, scrollHeight, innerHeight } =
entry.getScrollState();
// Re-verify it hasn't become stale before flushing
if (trueScroll.expectedScrollTop !== scrollTop) {
trueScrollRef.current.set(id, {
floatValue: scrollTop,
expectedScrollTop: scrollTop,
});
continue;
}
const clampedFloat = getClampedScrollTop(
trueScroll.floatValue,
scrollHeight,
innerHeight,
);
const roundedTarget = Math.round(clampedFloat);
const deltaToApply = roundedTarget - scrollTop;
if (deltaToApply !== 0) {
entry.scrollBy(deltaToApply);
trueScroll.expectedScrollTop = roundedTarget;
}
trueScroll.floatValue = clampedFloat;
} else {
trueScrollRef.current.delete(id);
}
}
pendingScrollsRef.current.clear();
}, 0);
}
}, []);
@@ -129,6 +184,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
const scrollMomentumRef = useRef({
count: 0,
lastTime: 0,
lastDirection: null as 'up' | 'down' | null,
});
const handleScroll = (direction: 'up' | 'down', mouseEvent: MouseEvent) => {
@@ -137,8 +193,11 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
if (!terminalCapabilityManager.isGhosttyTerminal()) {
const timeSinceLastScroll = now - scrollMomentumRef.current.lastTime;
const isSameDirection =
scrollMomentumRef.current.lastDirection === direction;
// 50ms threshold to consider scrolls consecutive
if (timeSinceLastScroll < 50) {
if (timeSinceLastScroll < 50 && isSameDirection) {
scrollMomentumRef.current.count += 1;
// Accelerate up to 3x, starting after 5 consecutive scrolls.
// Each consecutive scroll increases the multiplier by 0.1.
@@ -151,6 +210,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
}
}
scrollMomentumRef.current.lastTime = now;
scrollMomentumRef.current.lastDirection = direction;
const delta = (direction === 'up' ? -1 : 1) * multiplier;
const candidates = findScrollableCandidates(
@@ -161,23 +221,33 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
for (const candidate of candidates) {
const { scrollTop, scrollHeight, innerHeight } =
candidate.getScrollState();
const pendingDelta = pendingScrollsRef.current.get(candidate.id) || 0;
const effectiveScrollTop = scrollTop + pendingDelta;
// Epsilon to handle floating point inaccuracies.
const canScrollUp = effectiveScrollTop > 0.001;
const canScrollDown =
effectiveScrollTop < scrollHeight - innerHeight - 0.001;
const totalDelta = Math.round(pendingDelta + delta);
if (direction === 'up' && canScrollUp) {
pendingScrollsRef.current.set(candidate.id, totalDelta);
scheduleFlush();
return true;
let trueScroll = trueScrollRef.current.get(candidate.id);
if (!trueScroll || trueScroll.expectedScrollTop !== scrollTop) {
trueScroll = { floatValue: scrollTop, expectedScrollTop: scrollTop };
}
if (direction === 'down' && canScrollDown) {
pendingScrollsRef.current.set(candidate.id, totalDelta);
const maxScroll = Math.max(0, scrollHeight - innerHeight);
const canScrollUp = trueScroll.floatValue > SCROLL_STATIC_FRICTION;
const canScrollDown =
trueScroll.floatValue < maxScroll - SCROLL_STATIC_FRICTION;
if (
(direction === 'up' && canScrollUp) ||
(direction === 'down' && canScrollDown)
) {
const clampedFloat = getClampedScrollTop(
trueScroll.floatValue + delta,
scrollHeight,
innerHeight,
);
trueScrollRef.current.set(candidate.id, {
floatValue: clampedFloat,
expectedScrollTop: trueScroll.expectedScrollTop,
});
pendingFlushRef.current.add(candidate.id);
scheduleFlush();
return true;
}
@@ -9,7 +9,6 @@ import type {
HistoryItem,
ThoughtSummary,
ConfirmationRequest,
QuotaStats,
LoopDetectionConfirmationRequest,
HistoryItemWithoutId,
StreamingState,
@@ -21,7 +20,6 @@ import type { CommandContext, SlashCommand } from '../commands/types.js';
import type {
IdeContext,
ApprovalMode,
UserTierId,
IdeInfo,
AuthType,
FallbackIntent,
@@ -86,16 +84,6 @@ import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
export interface QuotaState {
userTier: UserTierId | undefined;
stats: QuotaStats | undefined;
proQuotaRequest: ProQuotaDialogRequest | null;
validationRequest: ValidationDialogRequest | null;
// G1 AI Credits overage flow
overageMenuRequest: OverageMenuDialogRequest | null;
emptyWalletRequest: EmptyWalletDialogRequest | null;
}
export interface AccountSuspensionInfo {
message: string;
appealUrl?: string;
@@ -169,8 +157,6 @@ export interface UIState {
queueErrorMessage: string | null;
showApprovalModeIndicator: ApprovalMode;
allowPlanMode: boolean;
// Quota-related state
quota: QuotaState;
currentModel: string;
contextFileNames: string[];
errorCount: number;
+12 -6
View File
@@ -10,6 +10,7 @@ import {
MessageSenderType,
debugLogger,
geminiPartsToContentParts,
displayContentToString,
parseThought,
CoreToolCallStatus,
type ApprovalMode,
@@ -197,6 +198,7 @@ export const useAgentStream = ({
name: displayName,
originalRequestName: event.name,
description: desc,
display: event.display,
status: CoreToolCallStatus.Scheduled,
isClientInitiated: false,
renderOutputAsMarkdown: isOutputMarkdown,
@@ -222,10 +224,9 @@ export const useAgentStream = ({
else if (evtStatus === 'success')
status = CoreToolCallStatus.Success;
const display = event.display?.result;
const liveOutput =
event.displayContent?.[0]?.type === 'text'
? event.displayContent[0].text
: tc.resultDisplay;
displayContentToString(display) ?? tc.resultDisplay;
const progressMessage =
legacyState?.progressMessage ?? tc.progressMessage;
const progress = legacyState?.progress ?? tc.progress;
@@ -237,6 +238,9 @@ export const useAgentStream = ({
return {
...tc,
status,
display: event.display
? { ...tc.display, ...event.display }
: tc.display,
resultDisplay: liveOutput,
progressMessage,
progress,
@@ -255,16 +259,18 @@ export const useAgentStream = ({
const legacyState = event._meta?.legacyState;
const outputFile = legacyState?.outputFile;
const display = event.display?.result;
const resultDisplay =
event.displayContent?.[0]?.type === 'text'
? event.displayContent[0].text
: tc.resultDisplay;
displayContentToString(display) ?? tc.resultDisplay;
return {
...tc,
status: event.isError
? CoreToolCallStatus.Error
: CoreToolCallStatus.Success,
display: event.display
? { ...tc.display, ...event.display }
: tc.display,
resultDisplay,
outputFile,
};
@@ -6,6 +6,7 @@
import { useMemo } from 'react';
import { useUIState } from '../contexts/UIStateContext.js';
import { useQuotaState } from '../contexts/QuotaContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { CoreToolCallStatus, ApprovalMode } from '@google/gemini-cli-core';
import { type HistoryItemToolGroup, StreamingState } from '../types.js';
@@ -18,6 +19,7 @@ import { theme } from '../semantic-colors.js';
*/
export const useComposerStatus = () => {
const uiState = useUIState();
const quotaState = useQuotaState();
const settings = useSettings();
const hasPendingToolConfirmation = useMemo(
@@ -40,8 +42,8 @@ export const useComposerStatus = () => {
Boolean(uiState.authConsentRequest) ||
(uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 ||
Boolean(uiState.loopDetectionConfirmationRequest) ||
Boolean(uiState.quota.proQuotaRequest) ||
Boolean(uiState.quota.validationRequest) ||
Boolean(quotaState.proQuotaRequest) ||
Boolean(quotaState.validationRequest) ||
Boolean(uiState.customDialog);
const isInteractiveShellWaiting = Boolean(
@@ -1,71 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { useTimedMessage } from './useTimedMessage.js';
describe('useTimedMessage', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('resets the timeout when the same message is retriggered', async () => {
const { result, unmount } = await renderHook(() => useTimedMessage(1000));
act(() => {
result.current[1]('hint');
});
expect(result.current[0]).toBe('hint');
act(() => {
vi.advanceTimersByTime(500);
});
act(() => {
result.current[1]('hint');
});
expect(result.current[0]).toBe('hint');
act(() => {
vi.advanceTimersByTime(500);
});
expect(result.current[0]).toBe('hint');
act(() => {
vi.advanceTimersByTime(500);
});
expect(result.current[0]).toBeNull();
unmount();
});
it('clears the message immediately when asked to hide it', async () => {
const { result, unmount } = await renderHook(() => useTimedMessage(1000));
act(() => {
result.current[1]('hint');
});
expect(result.current[0]).toBe('hint');
act(() => {
result.current[1](null);
});
expect(result.current[0]).toBeNull();
act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current[0]).toBeNull();
unmount();
});
});
+2 -13
View File
@@ -12,29 +12,19 @@ import { useState, useCallback, useRef, useEffect } from 'react';
*/
export function useTimedMessage<T>(durationMs: number) {
const [message, setMessage] = useState<T | null>(null);
const messageRef = useRef<T | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const showMessage = useCallback(
(msg: T | null) => {
setMessage(msg);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (msg !== null) {
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
messageRef.current = null;
setMessage((prev) => (prev === null ? prev : null));
setMessage(null);
}, durationMs);
}
if (Object.is(messageRef.current, msg)) {
return;
}
messageRef.current = msg;
setMessage(msg);
},
[durationMs],
);
@@ -43,7 +33,6 @@ export function useTimedMessage<T>(durationMs: number) {
() => () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
},
[],
+2
View File
@@ -11,6 +11,7 @@ import {
type ThoughtSummary,
type SerializableConfirmationDetails,
type ToolResultDisplay,
type ToolDisplay,
type RetrieveUserQuotaResponse,
type SkillDefinition,
type AgentDefinition,
@@ -121,6 +122,7 @@ export interface IndividualToolCallDisplay {
name: string;
args?: Record<string, unknown>;
description: string;
display?: ToolDisplay;
resultDisplay: ToolResultDisplay | undefined;
status: CoreToolCallStatus;
// True when the tool was initiated directly by the user (slash/@/shell flows).
@@ -1,50 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { shouldAutoTriggerExpandHint } from './expandHint.js';
describe('shouldAutoTriggerExpandHint', () => {
it('returns true when constrained content gains a new overflowing region', () => {
expect(
shouldAutoTriggerExpandHint({
constrainHeight: true,
overflowingIdsSize: 2,
previousOverflowingIdsSize: 1,
}),
).toBe(true);
});
it('returns false when overflowingIdsSize decreases', () => {
expect(
shouldAutoTriggerExpandHint({
constrainHeight: true,
overflowingIdsSize: 1,
previousOverflowingIdsSize: 2,
}),
).toBe(false);
});
it('returns false when overflowingIdsSize is unchanged', () => {
expect(
shouldAutoTriggerExpandHint({
constrainHeight: true,
overflowingIdsSize: 1,
previousOverflowingIdsSize: 1,
}),
).toBe(false);
});
it('returns false while content is already expanded', () => {
expect(
shouldAutoTriggerExpandHint({
constrainHeight: false,
overflowingIdsSize: 2,
previousOverflowingIdsSize: 1,
}),
).toBe(false);
});
});
-19
View File
@@ -1,19 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
interface ExpandHintAutoTriggerParams {
constrainHeight: boolean;
overflowingIdsSize: number;
previousOverflowingIdsSize: number;
}
export function shouldAutoTriggerExpandHint({
constrainHeight,
overflowingIdsSize,
previousOverflowingIdsSize,
}: ExpandHintAutoTriggerParams): boolean {
return constrainHeight && overflowingIdsSize > previousOverflowingIdsSize;
}
+1
View File
@@ -68,6 +68,7 @@
"https-proxy-agent": "^7.0.6",
"ignore": "^7.0.0",
"ipaddr.js": "^1.9.1",
"isbinaryfile": "^5.0.7",
"js-yaml": "^4.1.1",
"json-stable-stringify": "^1.3.0",
"marked": "^15.0.12",
@@ -8,7 +8,6 @@ import { describe, expect, it } from 'vitest';
import {
geminiPartsToContentParts,
contentPartsToGeminiParts,
toolResultDisplayToContentParts,
buildToolResponseData,
} from './content-utils.js';
import type { Part } from '@google/genai';
@@ -200,27 +199,6 @@ describe('contentPartsToGeminiParts', () => {
});
});
describe('toolResultDisplayToContentParts', () => {
it('returns undefined for undefined', () => {
expect(toolResultDisplayToContentParts(undefined)).toBeUndefined();
});
it('returns undefined for null', () => {
expect(toolResultDisplayToContentParts(null)).toBeUndefined();
});
it('handles string resultDisplay as-is', () => {
const result = toolResultDisplayToContentParts('File written');
expect(result).toEqual([{ type: 'text', text: 'File written' }]);
});
it('stringifies object resultDisplay', () => {
const display = { type: 'FileDiff', oldPath: 'a.ts', newPath: 'b.ts' };
const result = toolResultDisplayToContentParts(display);
expect(result).toEqual([{ type: 'text', text: JSON.stringify(display) }]);
});
});
describe('buildToolResponseData', () => {
it('preserves outputFile and contentLength', () => {
const result = buildToolResponseData({
-18
View File
@@ -101,24 +101,6 @@ export function contentPartsToGeminiParts(content: ContentPart[]): Part[] {
return result;
}
/**
* Converts a ToolCallResponseInfo.resultDisplay value into ContentPart[].
* Handles string, object-valued (FileDiff, SubagentProgress, etc.),
* and undefined resultDisplay consistently.
*/
export function toolResultDisplayToContentParts(
resultDisplay: unknown,
): ContentPart[] | undefined {
if (resultDisplay === undefined || resultDisplay === null) {
return undefined;
}
const text =
typeof resultDisplay === 'string'
? resultDisplay
: JSON.stringify(resultDisplay);
return [{ type: 'text', text }];
}
/**
* Builds the data record for a tool_response AgentEvent, preserving
* all available metadata from the ToolCallResponseInfo.
@@ -155,9 +155,10 @@ describe('translateEvent', () => {
expect(resp.content).toEqual([
{ type: 'text', text: 'Permission denied to write' },
]);
expect(resp.displayContent).toEqual([
{ type: 'text', text: 'Permission denied' },
]);
expect(resp.display?.result).toEqual({
type: 'text',
text: 'Permission denied',
});
expect(resp.data).toEqual({ errorType: 'permission_denied' });
});
@@ -200,9 +201,12 @@ describe('translateEvent', () => {
};
const result = translateEvent(event, state);
const resp = result[0] as AgentEvent<'tool_response'>;
expect(resp.displayContent).toEqual([
{ type: 'text', text: JSON.stringify(objectDisplay) },
]);
expect(resp.display?.result).toEqual({
type: 'diff',
path: '/tmp/test.txt',
beforeText: 'a',
afterText: 'b',
});
});
it('passes through string resultDisplay as-is', () => {
@@ -220,9 +224,10 @@ describe('translateEvent', () => {
};
const result = translateEvent(event, state);
const resp = result[0] as AgentEvent<'tool_response'>;
expect(resp.displayContent).toEqual([
{ type: 'text', text: 'Command output text' },
]);
expect(resp.display?.result).toEqual({
type: 'text',
text: 'Command output text',
});
});
it('preserves outputFile and contentLength in data', () => {
+10 -5
View File
@@ -25,12 +25,13 @@ import type {
ErrorData,
Usage,
AgentEventType,
ToolDisplay,
} from './types.js';
import {
geminiPartsToContentParts,
toolResultDisplayToContentParts,
buildToolResponseData,
} from './content-utils.js';
import { toolResultDisplayToDisplayContent } from './tool-display-utils.js';
// ---------------------------------------------------------------------------
// Translation State
@@ -241,10 +242,14 @@ export function translateEvent(
case GeminiEventType.ToolCallResponse: {
ensureStreamStart(state, out);
const displayContent = toolResultDisplayToContentParts(
event.value.resultDisplay,
);
const data = buildToolResponseData(event.value);
const display: ToolDisplay | undefined = event.value.resultDisplay
? {
result: toolResultDisplayToDisplayContent(
event.value.resultDisplay,
),
}
: undefined;
out.push(
makeEvent('tool_response', state, {
requestId: event.value.callId,
@@ -253,7 +258,7 @@ export function translateEvent(
? [{ type: 'text', text: event.value.error.message }]
: geminiPartsToContentParts(event.value.responseParts),
isError: event.value.error !== undefined,
...(displayContent ? { displayContent } : {}),
...(display ? { display } : {}),
...(data ? { data } : {}),
}),
);
@@ -489,9 +489,10 @@ describe('LegacyAgentSession', () => {
expect(toolResp?.content).toEqual([
{ type: 'text', text: 'Permission denied' },
]);
expect(toolResp?.displayContent).toEqual([
{ type: 'text', text: 'Error display' },
]);
expect(toolResp?.display?.result).toEqual({
type: 'text',
text: 'Error display',
});
});
it('stops on STOP_EXECUTION tool error', async () => {
@@ -23,8 +23,8 @@ import {
buildToolResponseData,
contentPartsToGeminiParts,
geminiPartsToContentParts,
toolResultDisplayToContentParts,
} from './content-utils.js';
import { populateToolDisplay } from './tool-display-utils.js';
import { AgentSession } from './agent-session.js';
import {
createTranslationState,
@@ -262,9 +262,12 @@ export class LegacyAgentProtocol implements AgentProtocol {
const content: ContentPart[] = response.error
? [{ type: 'text', text: response.error.message }]
: geminiPartsToContentParts(response.responseParts);
const displayContent = toolResultDisplayToContentParts(
response.resultDisplay,
);
const display = populateToolDisplay({
name: request.name,
invocation: 'invocation' in tc ? tc.invocation : undefined,
resultDisplay: response.resultDisplay,
displayName: 'tool' in tc ? tc.tool?.displayName : undefined,
});
const data = buildToolResponseData(response);
this._emit([
@@ -273,7 +276,7 @@ export class LegacyAgentProtocol implements AgentProtocol {
name: request.name,
content,
isError: response.error !== undefined,
...(displayContent ? { displayContent } : {}),
...(display ? { display } : {}),
...(data ? { data } : {}),
}),
]);
@@ -0,0 +1,124 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import type {
ToolInvocation,
ToolResult,
ToolResultDisplay,
} from '../tools/tools.js';
import type { DisplayContent } from './types.js';
import {
populateToolDisplay,
renderDisplayDiff,
displayContentToString,
} from './tool-display-utils.js';
describe('tool-display-utils', () => {
describe('populateToolDisplay', () => {
it('uses displayName if provided', () => {
const mockInvocation = {
getDescription: () => 'Doing something...',
} as unknown as ToolInvocation<object, ToolResult>;
const display = populateToolDisplay({
name: 'raw-name',
invocation: mockInvocation,
displayName: 'Custom Display Name',
});
expect(display.name).toBe('Custom Display Name');
expect(display.description).toBe('Doing something...');
});
it('falls back to raw name if no displayName provided', () => {
const mockInvocation = {
getDescription: () => 'Doing something...',
} as unknown as ToolInvocation<object, ToolResult>;
const display = populateToolDisplay({
name: 'raw-name',
invocation: mockInvocation,
});
expect(display.name).toBe('raw-name');
});
it('populates result from resultDisplay', () => {
const display = populateToolDisplay({
name: 'test',
resultDisplay: 'hello world',
});
expect(display.result).toEqual({ type: 'text', text: 'hello world' });
});
it('translates FileDiff to DisplayDiff', () => {
const fileDiff = {
fileDiff: '@@ ...',
fileName: 'test.ts',
filePath: 'src/test.ts',
originalContent: 'old',
newContent: 'new',
} as unknown as ToolResultDisplay;
const display = populateToolDisplay({
name: 'test',
resultDisplay: fileDiff,
});
expect(display.result).toEqual({
type: 'diff',
path: 'src/test.ts',
beforeText: 'old',
afterText: 'new',
});
});
});
describe('renderDisplayDiff', () => {
it('renders a universal diff', () => {
const diff = {
type: 'diff' as const,
path: 'test.ts',
beforeText: 'line 1\nline 2',
afterText: 'line 1\nline 2 modified',
};
const rendered = renderDisplayDiff(diff);
expect(rendered).toContain('--- test.ts\tOriginal');
expect(rendered).toContain('+++ test.ts\tModified');
expect(rendered).toContain('-line 2');
expect(rendered).toContain('+line 2 modified');
});
});
describe('displayContentToString', () => {
it('returns undefined for undefined input', () => {
expect(displayContentToString(undefined)).toBeUndefined();
});
it('returns text for text input', () => {
expect(displayContentToString({ type: 'text', text: 'hello' })).toBe(
'hello',
);
});
it('renders a diff for diff input', () => {
const diff = {
type: 'diff' as const,
path: 'test.ts',
beforeText: 'old',
afterText: 'new',
};
const rendered = displayContentToString(diff);
expect(rendered).toContain('--- test.ts\tOriginal');
expect(rendered).toContain('+++ test.ts\tModified');
});
it('stringifies unknown structured objects', () => {
const unknown = {
type: 'something_else',
data: 123,
} as unknown as DisplayContent;
expect(displayContentToString(unknown)).toBe(JSON.stringify(unknown));
});
});
});
@@ -0,0 +1,106 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as Diff from 'diff';
import type {
ToolInvocation,
ToolResult,
ToolResultDisplay,
} from '../tools/tools.js';
import type { ToolDisplay, DisplayContent, DisplayDiff } from './types.js';
/**
* Populates a ToolDisplay object from a tool invocation and its result.
* This serves as a centralized bridge during the migration to tool-controlled display.
*/
export function populateToolDisplay({
name,
invocation,
resultDisplay,
displayName,
}: {
name: string;
invocation?: ToolInvocation<object, ToolResult>;
resultDisplay?: ToolResultDisplay;
displayName?: string;
}): ToolDisplay {
const display: ToolDisplay = {
name: displayName || name,
description: invocation?.getDescription?.(),
};
if (resultDisplay) {
display.result = toolResultDisplayToDisplayContent(resultDisplay);
}
return display;
}
/**
* Converts a legacy ToolResultDisplay into the new DisplayContent format.
*/
export function toolResultDisplayToDisplayContent(
resultDisplay: ToolResultDisplay,
): DisplayContent {
if (typeof resultDisplay === 'string') {
return { type: 'text', text: resultDisplay };
}
// Handle FileDiff -> DisplayDiff
if (
typeof resultDisplay === 'object' &&
resultDisplay !== null &&
'fileDiff' in resultDisplay &&
'newContent' in resultDisplay
) {
return {
type: 'diff',
path: resultDisplay.filePath || resultDisplay.fileName,
beforeText: resultDisplay.originalContent ?? '',
afterText: resultDisplay.newContent,
};
}
// Fallback for other structured types (LsTool, GrepTool, etc.)
// These will be fully migrated in Step 5.
return {
type: 'text',
text: JSON.stringify(resultDisplay),
};
}
/**
* Renders a universal diff string from a DisplayDiff object.
*/
export function renderDisplayDiff(diff: DisplayDiff): string {
return Diff.createPatch(
diff.path || 'file',
diff.beforeText,
diff.afterText,
'Original',
'Modified',
{ context: 3 },
);
}
/**
* Converts a DisplayContent object into a string representation.
* Useful for fallback displays or non-interactive environments.
*/
export function displayContentToString(
display: DisplayContent | undefined,
): string | undefined {
if (!display) {
return undefined;
}
if (display.type === 'text') {
return display.text;
}
if (display.type === 'diff') {
return renderDisplayDiff(display);
}
return JSON.stringify(display);
}
+24 -5
View File
@@ -106,7 +106,7 @@ export interface AgentEvents {
/** Updates configuration about the current session/agent. */
session_update: SessionUpdate;
/** Message content provided by user, agent, or developer. */
message: Message;
message: AgentMessage;
/** Event indicating the start of agent activity on a stream. */
agent_start: AgentStart;
/** Event indicating the end of agent activity on a stream. */
@@ -170,17 +170,35 @@ export type ContentPart =
) &
WithMeta;
export interface Message {
export interface AgentMessage {
role: 'user' | 'agent' | 'developer';
content: ContentPart[];
}
export type DisplayText = { type: 'text'; text: string };
export type DisplayDiff = {
type: 'diff';
path?: string;
beforeText: string;
afterText: string;
};
export type DisplayContent = DisplayText | DisplayDiff;
export interface ToolDisplay {
name?: string;
description?: string;
resultSummary?: string;
result?: DisplayContent;
}
export interface ToolRequest {
/** A unique identifier for this tool request to be correlated by the response. */
requestId: string;
/** The name of the tool being requested. */
name: string;
/** The arguments for the tool. */
/** Tool-controlled display information. */
display?: ToolDisplay;
args: Record<string, unknown>;
/** UI specific metadata */
_meta?: {
@@ -201,7 +219,8 @@ export interface ToolRequest {
*/
export interface ToolUpdate {
requestId: string;
displayContent?: ContentPart[];
/** Tool-controlled display information. */
display?: ToolDisplay;
content?: ContentPart[];
data?: Record<string, unknown>;
/** UI specific metadata */
@@ -221,8 +240,8 @@ export interface ToolUpdate {
export interface ToolResponse {
requestId: string;
name: string;
/** Content representing the tool call's outcome to be presented to the user. */
displayContent?: ContentPart[];
/** Tool-controlled display information. */
display?: ToolDisplay;
/** Multi-part content to be sent to the model. */
content?: ContentPart[];
/** Structured data to be sent to the model. */
@@ -493,6 +493,42 @@ Body`);
});
});
it('should convert mcp_servers with auth block in local agent (google-credentials)', () => {
const markdown = {
kind: 'local' as const,
name: 'spanner-test-agent',
description: 'An agent to test Spanner MCP with auth',
mcp_servers: {
spanner: {
url: 'https://spanner.googleapis.com/mcp',
type: 'http' as const,
auth: {
type: 'google-credentials' as const,
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
},
timeout: 30000,
},
},
system_prompt: 'You are a Spanner test agent.',
};
const result = markdownToAgentDefinition(
markdown,
) as LocalAgentDefinition;
expect(result.kind).toBe('local');
expect(result.mcpServers).toBeDefined();
expect(result.mcpServers!['spanner']).toMatchObject({
url: 'https://spanner.googleapis.com/mcp',
type: 'http',
authProviderType: 'google_credentials',
oauth: {
enabled: true,
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
},
timeout: 30000,
});
});
it('should pass through unknown model names (e.g. auto)', () => {
const markdown = {
kind: 'local' as const,
+53 -5
View File
@@ -17,7 +17,11 @@ import {
DEFAULT_MAX_TIME_MINUTES,
} from './types.js';
import type { A2AAuthConfig } from './auth-provider/types.js';
import { MCPServerConfig } from '../config/config.js';
import {
MCPServerConfig,
AuthProviderType,
type MCPOAuthConfig,
} from '../config/config.js';
import { isValidToolName } from '../tools/tool-names.js';
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
import { getErrorMessage } from '../utils/errors.js';
@@ -62,6 +66,22 @@ const mcpServerSchema = z.object({
description: z.string().optional(),
include_tools: z.array(z.string()).optional(),
exclude_tools: z.array(z.string()).optional(),
auth: z
.union([
z.object({
type: z.literal('google-credentials'),
scopes: z.array(z.string()).optional(),
}),
z.object({
type: z.literal('oauth'),
client_id: z.string().optional(),
client_secret: z.string().optional(),
scopes: z.array(z.string()).optional(),
authorization_url: z.string().url().optional(),
token_url: z.string().url().optional(),
}),
])
.optional(),
});
const localAgentSchema = z
@@ -74,9 +94,12 @@ const localAgentSchema = z
.array(
z
.string()
.refine((val) => isValidToolName(val, { allowWildcards: true }), {
message: 'Invalid tool name',
}),
.refine(
(val: string) => isValidToolName(val, { allowWildcards: true }),
{
message: 'Invalid tool name',
},
),
)
.optional(),
mcp_servers: z.record(mcpServerSchema).optional(),
@@ -191,7 +214,7 @@ const remoteAgentJsonSchema = baseRemoteAgentSchema
.extend({
agent_card_url: z.undefined().optional(),
agent_card_json: z.string().refine(
(val) => {
(val: string) => {
try {
JSON.parse(val);
return true;
@@ -511,6 +534,28 @@ export function markdownToAgentDefinition(
const mcpServers: Record<string, MCPServerConfig> = {};
if (markdown.mcp_servers) {
for (const [name, config] of Object.entries(markdown.mcp_servers)) {
let authProviderType: AuthProviderType | undefined = undefined;
let oauth: MCPOAuthConfig | undefined = undefined;
if (config.auth) {
if (config.auth.type === 'google-credentials') {
authProviderType = AuthProviderType.GOOGLE_CREDENTIALS;
oauth = {
enabled: true,
scopes: config.auth.scopes,
};
} else if (config.auth.type === 'oauth') {
oauth = {
enabled: true,
clientId: config.auth.client_id,
clientSecret: config.auth.client_secret,
scopes: config.auth.scopes,
authorizationUrl: config.auth.authorization_url,
tokenUrl: config.auth.token_url,
};
}
}
mcpServers[name] = new MCPServerConfig(
config.command,
config.args,
@@ -526,6 +571,9 @@ export function markdownToAgentDefinition(
config.description,
config.include_tools,
config.exclude_tools,
undefined, // extension
oauth,
authProviderType,
);
}
}
@@ -170,6 +170,43 @@ function buildSystemPrompt(skillsDir: string): string {
'Naming: kebab-case (e.g., fix-lint-errors, run-migrations).',
'',
'============================================================',
'UPDATING EXISTING SKILLS (PATCHES)',
'============================================================',
'',
'You can ONLY write files inside your skills directory. However, existing skills',
'may live outside it (global or workspace locations).',
'',
'NEVER patch builtin or extension skills. They are managed externally and',
'overwritten on updates. Patches targeting these paths will be rejected.',
'',
'To propose an update to an existing skill that lives OUTSIDE your directory:',
'',
'1. Read the original file(s) using read_file (paths are listed in "Existing Skills").',
'2. Write a unified diff patch file to:',
` ${skillsDir}/<skill-name>.patch`,
'',
'Patch format (strict unified diff):',
'',
' --- /absolute/path/to/original/SKILL.md',
' +++ /absolute/path/to/original/SKILL.md',
' @@ -<start>,<count> +<start>,<count> @@',
' <context line>',
' -<removed line>',
' +<added line>',
' <context line>',
'',
'Rules for patches:',
'- Use the EXACT absolute file path in BOTH --- and +++ headers (NO a/ or b/ prefixes).',
'- Include 3 lines of context around each change (standard unified diff).',
'- A single .patch file can contain hunks for multiple files in the same skill.',
'- For new files, use `/dev/null` as the --- source.',
'- Line counts in @@ headers MUST be accurate.',
'- Do NOT create a patch if you can create or update a skill in your own directory instead.',
'- Patches will be validated by parsing and dry-run applying them. Invalid patches are discarded.',
'',
'The same quality bar applies: only propose updates backed by evidence from sessions.',
'',
'============================================================',
'QUALITY RULES (STRICT)',
'============================================================',
'',
@@ -192,7 +229,8 @@ function buildSystemPrompt(skillsDir: string): string {
'5. For promising patterns, use read_file on the session file paths to inspect the full',
' conversation. Confirm the workflow was actually repeated and validated.',
'6. For each confirmed skill, verify it meets ALL criteria (repeatable, procedural, high-leverage).',
'7. Write new SKILL.md files or update existing ones using write_file.',
'7. Write new SKILL.md files or update existing ones in your directory using write_file.',
' For skills that live OUTSIDE your directory, write a .patch file instead (see UPDATING EXISTING SKILLS).',
'8. Write COMPLETE files — never partially update a SKILL.md.',
'',
'IMPORTANT: Do NOT read every session. Only read sessions whose summaries suggest a',
@@ -41,7 +41,7 @@ const DEFAULT_ACTIONS: ModelPolicyActionMap = {
unknown: 'prompt',
};
const SILENT_ACTIONS: ModelPolicyActionMap = {
export const SILENT_ACTIONS: ModelPolicyActionMap = {
terminal: 'silent',
transient: 'silent',
not_found: 'silent',
@@ -10,7 +10,7 @@ import {
buildFallbackPolicyContext,
applyModelSelection,
} from './policyHelpers.js';
import { createDefaultPolicy } from './policyCatalog.js';
import { createDefaultPolicy, SILENT_ACTIONS } from './policyCatalog.js';
import type { Config } from '../config/config.js';
import {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
@@ -21,6 +21,7 @@ import {
import { AuthType } from '../core/contentGenerator.js';
import { ModelConfigService } from '../services/modelConfigService.js';
import { DEFAULT_MODEL_CONFIGS } from '../config/defaultModelConfigs.js';
import { ApprovalMode } from '../policy/types.js';
const createMockConfig = (overrides: Partial<Config> = {}): Config => {
const config = {
@@ -164,6 +165,18 @@ describe('policyHelpers', () => {
expect(chain[0]?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
expect(chain[1]?.model).toBe('gemini-3-flash-preview');
});
it('applies SILENT_ACTIONS when ApprovalMode is PLAN', () => {
const config = createMockConfig({
getApprovalMode: () => ApprovalMode.PLAN,
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
});
const chain = resolvePolicyChain(config);
expect(chain).toHaveLength(2);
expect(chain[0]?.actions).toEqual(SILENT_ACTIONS);
expect(chain[1]?.actions).toEqual(SILENT_ACTIONS);
});
});
describe('resolvePolicyChain behavior is identical between dynamic and legacy implementations', () => {
+50 -38
View File
@@ -18,6 +18,7 @@ import {
createSingleModelChain,
getModelPolicyChain,
getFlashLitePolicyChain,
SILENT_ACTIONS,
} from './policyCatalog.js';
import {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
@@ -29,6 +30,7 @@ import {
} from '../config/models.js';
import type { ModelSelectionResult } from './modelAvailabilityService.js';
import type { ModelConfigKey } from '../services/modelConfigService.js';
import { ApprovalMode } from '../policy/types.js';
/**
* Resolves the active policy chain for the given config, ensuring the
@@ -43,7 +45,7 @@ export function resolvePolicyChain(
preferredModel ?? config.getActiveModel?.() ?? config.getModel();
const configuredModel = config.getModel();
let chain;
let chain: ModelPolicyChain | undefined;
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
const useGemini31FlashLite =
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
@@ -103,45 +105,55 @@ export function resolvePolicyChain(
// No matching modelChains found, default to single model chain
chain = createSingleModelChain(modelFromConfig);
}
return applyDynamicSlicing(chain, resolvedModel, wrapsAround);
}
// --- LEGACY PATH ---
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
chain = getFlashLitePolicyChain();
} else if (
isGemini3Model(resolvedModel, config) ||
isAutoPreferred ||
isAutoConfigured
) {
if (hasAccessToPreview) {
const previewEnabled =
isGemini3Model(resolvedModel, config) ||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
chain = getModelPolicyChain({
previewEnabled,
userTier: config.getUserTier(),
useGemini31,
useGemini31FlashLite,
useCustomToolModel,
});
} else {
// User requested Gemini 3 but has no access. Proactively downgrade
// to the stable Gemini 2.5 chain.
chain = getModelPolicyChain({
previewEnabled: false,
userTier: config.getUserTier(),
useGemini31,
useGemini31FlashLite,
useCustomToolModel,
});
}
chain = applyDynamicSlicing(chain, resolvedModel, wrapsAround);
} else {
chain = createSingleModelChain(modelFromConfig);
// --- LEGACY PATH ---
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
chain = getFlashLitePolicyChain();
} else if (
isGemini3Model(resolvedModel, config) ||
isAutoPreferred ||
isAutoConfigured
) {
if (hasAccessToPreview) {
const previewEnabled =
isGemini3Model(resolvedModel, config) ||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
chain = getModelPolicyChain({
previewEnabled,
userTier: config.getUserTier(),
useGemini31,
useGemini31FlashLite,
useCustomToolModel,
});
} else {
// User requested Gemini 3 but has no access. Proactively downgrade
// to the stable Gemini 2.5 chain.
chain = getModelPolicyChain({
previewEnabled: false,
userTier: config.getUserTier(),
useGemini31,
useGemini31FlashLite,
useCustomToolModel,
});
}
} else {
chain = createSingleModelChain(modelFromConfig);
}
chain = applyDynamicSlicing(chain, resolvedModel, wrapsAround);
}
return applyDynamicSlicing(chain, resolvedModel, wrapsAround);
// Apply Unified Silent Injection for Plan Mode with defensive checks
if (config?.getApprovalMode?.() === ApprovalMode.PLAN) {
return chain.map((policy) => ({
...policy,
actions: { ...SILENT_ACTIONS },
}));
}
return chain;
}
/**
+708
View File
@@ -14,6 +14,9 @@ import {
addMemory,
dismissInboxSkill,
listInboxSkills,
listInboxPatches,
applyInboxPatch,
dismissInboxPatch,
listMemoryFiles,
moveInboxSkill,
refreshMemory,
@@ -528,4 +531,709 @@ describe('memory commands', () => {
expect(result.message).toBe('Invalid skill name.');
});
});
describe('listInboxPatches', () => {
let tmpDir: string;
let skillsDir: string;
let memoryTempDir: string;
let patchConfig: Config;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'patch-list-test-'));
skillsDir = path.join(tmpDir, 'skills-memory');
memoryTempDir = path.join(tmpDir, 'memory-temp');
await fs.mkdir(skillsDir, { recursive: true });
await fs.mkdir(memoryTempDir, { recursive: true });
patchConfig = {
storage: {
getProjectSkillsMemoryDir: () => skillsDir,
getProjectMemoryTempDir: () => memoryTempDir,
},
} as unknown as Config;
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it('should return empty array when no patches exist', async () => {
const result = await listInboxPatches(patchConfig);
expect(result).toEqual([]);
});
it('should return empty array when directory does not exist', async () => {
const badConfig = {
storage: {
getProjectSkillsMemoryDir: () => path.join(tmpDir, 'nonexistent-dir'),
getProjectMemoryTempDir: () => memoryTempDir,
},
} as unknown as Config;
const result = await listInboxPatches(badConfig);
expect(result).toEqual([]);
});
it('should return parsed patch entries', async () => {
const targetFile = path.join(tmpDir, 'target.md');
const patchContent = [
`--- ${targetFile}`,
`+++ ${targetFile}`,
'@@ -1,3 +1,4 @@',
' line1',
' line2',
'+line2.5',
' line3',
'',
].join('\n');
await fs.writeFile(
path.join(skillsDir, 'update-skill.patch'),
patchContent,
);
const result = await listInboxPatches(patchConfig);
expect(result).toHaveLength(1);
expect(result[0].fileName).toBe('update-skill.patch');
expect(result[0].name).toBe('update-skill');
expect(result[0].entries).toHaveLength(1);
expect(result[0].entries[0].targetPath).toBe(targetFile);
expect(result[0].entries[0].diffContent).toContain('+line2.5');
});
it('should use each patch file mtime for extractedAt', async () => {
const firstTarget = path.join(tmpDir, 'first.md');
const secondTarget = path.join(tmpDir, 'second.md');
const firstTimestamp = new Date('2025-01-15T10:00:00.000Z');
const secondTimestamp = new Date('2025-01-16T12:00:00.000Z');
await fs.writeFile(
path.join(memoryTempDir, '.extraction-state.json'),
JSON.stringify({
runs: [
{
runAt: '2025-02-01T00:00:00Z',
sessionIds: ['later-run'],
skillsCreated: [],
},
],
}),
);
await fs.writeFile(
path.join(skillsDir, 'first.patch'),
[
`--- ${firstTarget}`,
`+++ ${firstTarget}`,
'@@ -1,1 +1,1 @@',
'-before',
'+after',
'',
].join('\n'),
);
await fs.writeFile(
path.join(skillsDir, 'second.patch'),
[
`--- ${secondTarget}`,
`+++ ${secondTarget}`,
'@@ -1,1 +1,1 @@',
'-before',
'+after',
'',
].join('\n'),
);
await fs.utimes(
path.join(skillsDir, 'first.patch'),
firstTimestamp,
firstTimestamp,
);
await fs.utimes(
path.join(skillsDir, 'second.patch'),
secondTimestamp,
secondTimestamp,
);
const result = await listInboxPatches(patchConfig);
const firstPatch = result.find(
(patch) => patch.fileName === 'first.patch',
);
const secondPatch = result.find(
(patch) => patch.fileName === 'second.patch',
);
expect(firstPatch?.extractedAt).toBe(firstTimestamp.toISOString());
expect(secondPatch?.extractedAt).toBe(secondTimestamp.toISOString());
});
it('should skip patches with no hunks', async () => {
await fs.writeFile(
path.join(skillsDir, 'empty.patch'),
'not a valid patch',
);
const result = await listInboxPatches(patchConfig);
expect(result).toEqual([]);
});
});
describe('applyInboxPatch', () => {
let tmpDir: string;
let skillsDir: string;
let memoryTempDir: string;
let globalSkillsDir: string;
let projectSkillsDir: string;
let applyConfig: Config;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'patch-apply-test-'));
skillsDir = path.join(tmpDir, 'skills-memory');
memoryTempDir = path.join(tmpDir, 'memory-temp');
globalSkillsDir = path.join(tmpDir, 'global-skills');
projectSkillsDir = path.join(tmpDir, 'project-skills');
await fs.mkdir(skillsDir, { recursive: true });
await fs.mkdir(memoryTempDir, { recursive: true });
await fs.mkdir(globalSkillsDir, { recursive: true });
await fs.mkdir(projectSkillsDir, { recursive: true });
applyConfig = {
storage: {
getProjectSkillsMemoryDir: () => skillsDir,
getProjectMemoryTempDir: () => memoryTempDir,
getProjectSkillsDir: () => projectSkillsDir,
},
isTrustedFolder: () => true,
} as unknown as Config;
vi.mocked(Storage.getUserSkillsDir).mockReturnValue(globalSkillsDir);
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it('should apply a valid patch and delete it', async () => {
const targetFile = path.join(projectSkillsDir, 'target.md');
await fs.writeFile(targetFile, 'line1\nline2\nline3\n');
const patchContent = [
`--- ${targetFile}`,
`+++ ${targetFile}`,
'@@ -1,3 +1,4 @@',
' line1',
' line2',
'+line2.5',
' line3',
'',
].join('\n');
const patchPath = path.join(skillsDir, 'good.patch');
await fs.writeFile(patchPath, patchContent);
const result = await applyInboxPatch(applyConfig, 'good.patch');
expect(result.success).toBe(true);
expect(result.message).toContain('Applied patch to 1 file');
// Verify target was modified
const modified = await fs.readFile(targetFile, 'utf-8');
expect(modified).toContain('line2.5');
// Verify patch was deleted
await expect(fs.access(patchPath)).rejects.toThrow();
});
it('should apply a multi-file patch', async () => {
const file1 = path.join(globalSkillsDir, 'file1.md');
const file2 = path.join(projectSkillsDir, 'file2.md');
await fs.writeFile(file1, 'aaa\nbbb\nccc\n');
await fs.writeFile(file2, 'xxx\nyyy\nzzz\n');
const patchContent = [
`--- ${file1}`,
`+++ ${file1}`,
'@@ -1,3 +1,4 @@',
' aaa',
' bbb',
'+bbb2',
' ccc',
`--- ${file2}`,
`+++ ${file2}`,
'@@ -1,3 +1,4 @@',
' xxx',
' yyy',
'+yyy2',
' zzz',
'',
].join('\n');
await fs.writeFile(path.join(skillsDir, 'multi.patch'), patchContent);
const result = await applyInboxPatch(applyConfig, 'multi.patch');
expect(result.success).toBe(true);
expect(result.message).toContain('2 files');
expect(await fs.readFile(file1, 'utf-8')).toContain('bbb2');
expect(await fs.readFile(file2, 'utf-8')).toContain('yyy2');
});
it('should apply repeated file blocks against the cumulative patched content', async () => {
const targetFile = path.join(projectSkillsDir, 'target.md');
await fs.writeFile(targetFile, 'alpha\nbeta\ngamma\ndelta\n');
await fs.writeFile(
path.join(skillsDir, 'multi-section.patch'),
[
`--- ${targetFile}`,
`+++ ${targetFile}`,
'@@ -1,4 +1,5 @@',
' alpha',
' beta',
'+beta2',
' gamma',
' delta',
`--- ${targetFile}`,
`+++ ${targetFile}`,
'@@ -2,4 +2,5 @@',
' beta',
' beta2',
' gamma',
'+gamma2',
' delta',
'',
].join('\n'),
);
const result = await applyInboxPatch(applyConfig, 'multi-section.patch');
expect(result.success).toBe(true);
expect(result.message).toContain('Applied patch to 1 file');
expect(await fs.readFile(targetFile, 'utf-8')).toBe(
'alpha\nbeta\nbeta2\ngamma\ngamma2\ndelta\n',
);
});
it('should reject /dev/null patches that target an existing skill file', async () => {
const targetFile = path.join(projectSkillsDir, 'existing-skill.md');
await fs.writeFile(targetFile, 'original content\n');
const patchPath = path.join(skillsDir, 'bad-new-file.patch');
await fs.writeFile(
patchPath,
[
'--- /dev/null',
`+++ ${targetFile}`,
'@@ -0,0 +1 @@',
'+replacement content',
'',
].join('\n'),
);
const result = await applyInboxPatch(applyConfig, 'bad-new-file.patch');
expect(result.success).toBe(false);
expect(result.message).toContain('target already exists');
expect(await fs.readFile(targetFile, 'utf-8')).toBe('original content\n');
await expect(fs.access(patchPath)).resolves.toBeUndefined();
});
it('should fail when patch does not exist', async () => {
const result = await applyInboxPatch(applyConfig, 'missing.patch');
expect(result.success).toBe(false);
expect(result.message).toContain('not found');
});
it('should reject invalid patch file names', async () => {
const outsidePatch = path.join(tmpDir, 'outside.patch');
await fs.writeFile(outsidePatch, 'outside patch content');
const result = await applyInboxPatch(applyConfig, '../outside.patch');
expect(result.success).toBe(false);
expect(result.message).toBe('Invalid patch file name.');
await expect(fs.access(outsidePatch)).resolves.toBeUndefined();
});
it('should fail when target file does not exist', async () => {
const missingFile = path.join(projectSkillsDir, 'missing-target.md');
const patchContent = [
`--- ${missingFile}`,
`+++ ${missingFile}`,
'@@ -1,3 +1,4 @@',
' a',
' b',
'+c',
' d',
'',
].join('\n');
await fs.writeFile(
path.join(skillsDir, 'bad-target.patch'),
patchContent,
);
const result = await applyInboxPatch(applyConfig, 'bad-target.patch');
expect(result.success).toBe(false);
expect(result.message).toContain('Target file not found');
});
it('should reject targets outside the global and workspace skill roots', async () => {
const outsideFile = path.join(tmpDir, 'outside.md');
await fs.writeFile(outsideFile, 'line1\nline2\nline3\n');
const patchContent = [
`--- ${outsideFile}`,
`+++ ${outsideFile}`,
'@@ -1,3 +1,4 @@',
' line1',
' line2',
'+line2.5',
' line3',
'',
].join('\n');
const patchPath = path.join(skillsDir, 'outside.patch');
await fs.writeFile(patchPath, patchContent);
const result = await applyInboxPatch(applyConfig, 'outside.patch');
expect(result.success).toBe(false);
expect(result.message).toContain(
'outside the global/workspace skill directories',
);
expect(await fs.readFile(outsideFile, 'utf-8')).not.toContain('line2.5');
await expect(fs.access(patchPath)).resolves.toBeUndefined();
});
it('should reject targets that escape the skill root through a symlinked parent', async () => {
const outsideDir = path.join(tmpDir, 'outside-dir');
const linkDir = path.join(projectSkillsDir, 'linked');
await fs.mkdir(outsideDir, { recursive: true });
await fs.symlink(
outsideDir,
linkDir,
process.platform === 'win32' ? 'junction' : 'dir',
);
const outsideFile = path.join(outsideDir, 'escaped.md');
await fs.writeFile(outsideFile, 'line1\nline2\nline3\n');
const patchPath = path.join(skillsDir, 'symlink.patch');
await fs.writeFile(
patchPath,
[
`--- ${path.join(linkDir, 'escaped.md')}`,
`+++ ${path.join(linkDir, 'escaped.md')}`,
'@@ -1,3 +1,4 @@',
' line1',
' line2',
'+line2.5',
' line3',
'',
].join('\n'),
);
const result = await applyInboxPatch(applyConfig, 'symlink.patch');
expect(result.success).toBe(false);
expect(result.message).toContain(
'outside the global/workspace skill directories',
);
expect(await fs.readFile(outsideFile, 'utf-8')).not.toContain('line2.5');
await expect(fs.access(patchPath)).resolves.toBeUndefined();
});
it('should reject patches that contain no hunks', async () => {
await fs.writeFile(
path.join(skillsDir, 'empty.patch'),
[
`--- ${path.join(projectSkillsDir, 'target.md')}`,
`+++ ${path.join(projectSkillsDir, 'target.md')}`,
'',
].join('\n'),
);
const result = await applyInboxPatch(applyConfig, 'empty.patch');
expect(result.success).toBe(false);
expect(result.message).toContain('contains no valid hunks');
});
it('should reject project-scope patches when the workspace is untrusted', async () => {
const targetFile = path.join(projectSkillsDir, 'target.md');
await fs.writeFile(targetFile, 'line1\nline2\nline3\n');
const patchPath = path.join(skillsDir, 'workspace.patch');
await fs.writeFile(
patchPath,
[
`--- ${targetFile}`,
`+++ ${targetFile}`,
'@@ -1,3 +1,4 @@',
' line1',
' line2',
'+line2.5',
' line3',
'',
].join('\n'),
);
const untrustedConfig = {
storage: applyConfig.storage,
isTrustedFolder: () => false,
} as Config;
const result = await applyInboxPatch(untrustedConfig, 'workspace.patch');
expect(result.success).toBe(false);
expect(result.message).toContain(
'Project skill patches are unavailable until this workspace is trusted.',
);
expect(await fs.readFile(targetFile, 'utf-8')).toBe(
'line1\nline2\nline3\n',
);
await expect(fs.access(patchPath)).resolves.toBeUndefined();
});
it('should reject project-scope patches through a symlinked project skills root when the workspace is untrusted', async () => {
const realProjectSkillsDir = path.join(tmpDir, 'project-skills-real');
const symlinkedProjectSkillsDir = path.join(
tmpDir,
'project-skills-link',
);
await fs.mkdir(realProjectSkillsDir, { recursive: true });
await fs.symlink(
realProjectSkillsDir,
symlinkedProjectSkillsDir,
process.platform === 'win32' ? 'junction' : 'dir',
);
projectSkillsDir = symlinkedProjectSkillsDir;
const targetFile = path.join(realProjectSkillsDir, 'target.md');
await fs.writeFile(targetFile, 'line1\nline2\nline3\n');
const patchPath = path.join(skillsDir, 'workspace-symlink.patch');
await fs.writeFile(
patchPath,
[
`--- ${targetFile}`,
`+++ ${targetFile}`,
'@@ -1,3 +1,4 @@',
' line1',
' line2',
'+line2.5',
' line3',
'',
].join('\n'),
);
const untrustedConfig = {
storage: applyConfig.storage,
isTrustedFolder: () => false,
} as Config;
const result = await applyInboxPatch(
untrustedConfig,
'workspace-symlink.patch',
);
expect(result.success).toBe(false);
expect(result.message).toContain(
'Project skill patches are unavailable until this workspace is trusted.',
);
expect(await fs.readFile(targetFile, 'utf-8')).toBe(
'line1\nline2\nline3\n',
);
await expect(fs.access(patchPath)).resolves.toBeUndefined();
});
it('should reject patches with mismatched diff headers', async () => {
const sourceFile = path.join(projectSkillsDir, 'source.md');
const targetFile = path.join(projectSkillsDir, 'target.md');
await fs.writeFile(sourceFile, 'aaa\nbbb\nccc\n');
await fs.writeFile(targetFile, 'xxx\nyyy\nzzz\n');
const patchPath = path.join(skillsDir, 'mismatched-headers.patch');
await fs.writeFile(
patchPath,
[
`--- ${sourceFile}`,
`+++ ${targetFile}`,
'@@ -1,3 +1,4 @@',
' xxx',
' yyy',
'+yyy2',
' zzz',
'',
].join('\n'),
);
const result = await applyInboxPatch(
applyConfig,
'mismatched-headers.patch',
);
expect(result.success).toBe(false);
expect(result.message).toContain('invalid diff headers');
expect(await fs.readFile(sourceFile, 'utf-8')).toBe('aaa\nbbb\nccc\n');
expect(await fs.readFile(targetFile, 'utf-8')).toBe('xxx\nyyy\nzzz\n');
await expect(fs.access(patchPath)).resolves.toBeUndefined();
});
it('should strip git-style a/ and b/ prefixes and apply successfully', async () => {
const targetFile = path.join(projectSkillsDir, 'prefixed.md');
await fs.writeFile(targetFile, 'line1\nline2\nline3\n');
const patchPath = path.join(skillsDir, 'git-prefix.patch');
await fs.writeFile(
patchPath,
[
`--- a/${targetFile}`,
`+++ b/${targetFile}`,
'@@ -1,3 +1,4 @@',
' line1',
' line2',
'+line2.5',
' line3',
'',
].join('\n'),
);
const result = await applyInboxPatch(applyConfig, 'git-prefix.patch');
expect(result.success).toBe(true);
expect(result.message).toContain('Applied patch to 1 file');
expect(await fs.readFile(targetFile, 'utf-8')).toBe(
'line1\nline2\nline2.5\nline3\n',
);
await expect(fs.access(patchPath)).rejects.toThrow();
});
it('should not write any files if one patch in a multi-file set fails', async () => {
const file1 = path.join(projectSkillsDir, 'file1.md');
await fs.writeFile(file1, 'aaa\nbbb\nccc\n');
const missingFile = path.join(projectSkillsDir, 'missing.md');
const patchContent = [
`--- ${file1}`,
`+++ ${file1}`,
'@@ -1,3 +1,4 @@',
' aaa',
' bbb',
'+bbb2',
' ccc',
`--- ${missingFile}`,
`+++ ${missingFile}`,
'@@ -1,3 +1,4 @@',
' x',
' y',
'+z',
' w',
'',
].join('\n');
await fs.writeFile(path.join(skillsDir, 'partial.patch'), patchContent);
const result = await applyInboxPatch(applyConfig, 'partial.patch');
expect(result.success).toBe(false);
// Verify file1 was NOT modified (dry-run failed)
const content = await fs.readFile(file1, 'utf-8');
expect(content).not.toContain('bbb2');
});
it('should roll back earlier file updates if a later commit step fails', async () => {
const file1 = path.join(projectSkillsDir, 'file1.md');
await fs.writeFile(file1, 'aaa\nbbb\nccc\n');
const conflictPath = path.join(projectSkillsDir, 'conflict');
const nestedNewFile = path.join(conflictPath, 'nested.md');
const patchPath = path.join(skillsDir, 'rollback.patch');
await fs.writeFile(
patchPath,
[
`--- ${file1}`,
`+++ ${file1}`,
'@@ -1,3 +1,4 @@',
' aaa',
' bbb',
'+bbb2',
' ccc',
'--- /dev/null',
`+++ ${conflictPath}`,
'@@ -0,0 +1 @@',
'+new file content',
'--- /dev/null',
`+++ ${nestedNewFile}`,
'@@ -0,0 +1 @@',
'+nested new file content',
'',
].join('\n'),
);
const result = await applyInboxPatch(applyConfig, 'rollback.patch');
expect(result.success).toBe(false);
expect(result.message).toContain('could not be applied atomically');
expect(await fs.readFile(file1, 'utf-8')).toBe('aaa\nbbb\nccc\n');
expect((await fs.stat(conflictPath)).isDirectory()).toBe(true);
await expect(fs.access(nestedNewFile)).rejects.toThrow();
await expect(fs.access(patchPath)).resolves.toBeUndefined();
});
});
describe('dismissInboxPatch', () => {
let tmpDir: string;
let skillsDir: string;
let dismissPatchConfig: Config;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'patch-dismiss-test-'));
skillsDir = path.join(tmpDir, 'skills-memory');
await fs.mkdir(skillsDir, { recursive: true });
dismissPatchConfig = {
storage: {
getProjectSkillsMemoryDir: () => skillsDir,
},
} as unknown as Config;
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it('should delete the patch file and return success', async () => {
const patchPath = path.join(skillsDir, 'old.patch');
await fs.writeFile(patchPath, 'some patch content');
const result = await dismissInboxPatch(dismissPatchConfig, 'old.patch');
expect(result.success).toBe(true);
expect(result.message).toContain('Dismissed');
await expect(fs.access(patchPath)).rejects.toThrow();
});
it('should return error when patch does not exist', async () => {
const result = await dismissInboxPatch(
dismissPatchConfig,
'nonexistent.patch',
);
expect(result.success).toBe(false);
expect(result.message).toContain('not found');
});
it('should reject invalid patch file names', async () => {
const outsidePatch = path.join(tmpDir, 'outside.patch');
await fs.writeFile(outsidePatch, 'outside patch content');
const result = await dismissInboxPatch(
dismissPatchConfig,
'../outside.patch',
);
expect(result.success).toBe(false);
expect(result.message).toBe('Invalid patch file name.');
await expect(fs.access(outsidePatch)).resolves.toBeUndefined();
});
});
});
+475
View File
@@ -4,12 +4,22 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { constants as fsConstants } from 'node:fs';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as Diff from 'diff';
import type { Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import { flattenMemory } from '../config/memory.js';
import { loadSkillFromFile, loadSkillsFromDir } from '../skills/skillLoader.js';
import {
type AppliedSkillPatchTarget,
applyParsedSkillPatches,
hasParsedPatchHunks,
isProjectSkillPatchTarget,
validateParsedSkillPatchHeaders,
} from '../services/memoryPatchUtils.js';
import { readExtractionState } from '../services/memoryService.js';
import { refreshServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
import type { MessageActionReturn, ToolActionReturn } from './types.js';
@@ -111,6 +121,8 @@ export interface InboxSkill {
name: string;
/** Skill description from SKILL.md frontmatter. */
description: string;
/** Raw SKILL.md content for preview. */
content: string;
/** When the skill was extracted (ISO string), if known. */
extractedAt?: string;
}
@@ -153,10 +165,18 @@ export async function listInboxSkills(config: Config): Promise<InboxSkill[]> {
const skillDef = await loadSkillFromFile(skillPath);
if (!skillDef) continue;
let content = '';
try {
content = await fs.readFile(skillPath, 'utf-8');
} catch {
// Best-effort — preview will be empty
}
skills.push({
dirName: dir.name,
name: skillDef.name,
description: skillDef.description,
content,
extractedAt: skillDateMap.get(dir.name),
});
}
@@ -176,6 +196,16 @@ function isValidInboxSkillDirName(dirName: string): boolean {
);
}
function isValidInboxPatchFileName(fileName: string): boolean {
return (
fileName.length > 0 &&
fileName !== '.' &&
fileName !== '..' &&
!fileName.includes('/') &&
!fileName.includes('\\')
);
}
async function getSkillNameForConflictCheck(
skillDir: string,
fallbackName: string,
@@ -283,3 +313,448 @@ export async function dismissInboxSkill(
message: `Dismissed "${dirName}" from inbox.`,
};
}
/**
* A parsed patch entry from a unified diff, representing changes to a single file.
*/
export interface InboxPatchEntry {
/** Absolute path to the target file (or '/dev/null' for new files). */
targetPath: string;
/** The unified diff text for this single file. */
diffContent: string;
}
/**
* Represents a .patch file found in the extraction inbox.
*/
export interface InboxPatch {
/** The .patch filename (e.g. "update-docs-writer.patch"). */
fileName: string;
/** Display name (filename without .patch extension). */
name: string;
/** Per-file entries parsed from the patch. */
entries: InboxPatchEntry[];
/** When the patch was extracted (ISO string), if known. */
extractedAt?: string;
}
interface StagedInboxPatchTarget {
targetPath: string;
tempPath: string;
original: string;
isNewFile: boolean;
mode?: number;
}
/**
* Reconstructs a unified diff string for a single ParsedDiff entry.
*/
function formatParsedDiff(parsed: Diff.StructuredPatch): string {
const lines: string[] = [];
if (parsed.oldFileName) {
lines.push(`--- ${parsed.oldFileName}`);
}
if (parsed.newFileName) {
lines.push(`+++ ${parsed.newFileName}`);
}
for (const hunk of parsed.hunks) {
lines.push(
`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
);
for (const line of hunk.lines) {
lines.push(line);
}
}
return lines.join('\n');
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function patchTargetsProjectSkills(
targetPaths: string[],
config: Config,
) {
for (const targetPath of targetPaths) {
if (await isProjectSkillPatchTarget(targetPath, config)) {
return true;
}
}
return false;
}
async function getPatchExtractedAt(
patchPath: string,
): Promise<string | undefined> {
try {
const stats = await fs.stat(patchPath);
return stats.mtime.toISOString();
} catch {
return undefined;
}
}
async function findNearestExistingDirectory(
startPath: string,
): Promise<string> {
let currentPath = path.resolve(startPath);
while (true) {
try {
const stats = await fs.stat(currentPath);
if (stats.isDirectory()) {
return currentPath;
}
} catch {
// Keep walking upward until we find an existing directory.
}
const parentPath = path.dirname(currentPath);
if (parentPath === currentPath) {
return currentPath;
}
currentPath = parentPath;
}
}
async function writeExclusiveFile(
filePath: string,
content: string,
mode?: number,
): Promise<void> {
const handle = await fs.open(filePath, 'wx');
try {
await handle.writeFile(content, 'utf-8');
} finally {
await handle.close();
}
if (mode !== undefined) {
await fs.chmod(filePath, mode);
}
}
async function cleanupStagedInboxPatchTargets(
stagedTargets: StagedInboxPatchTarget[],
): Promise<void> {
await Promise.allSettled(
stagedTargets.map(async ({ tempPath }) => {
try {
await fs.unlink(tempPath);
} catch {
// Best-effort cleanup.
}
}),
);
}
async function restoreCommittedInboxPatchTarget(
stagedTarget: StagedInboxPatchTarget,
): Promise<void> {
if (stagedTarget.isNewFile) {
try {
await fs.unlink(stagedTarget.targetPath);
} catch {
// Best-effort rollback.
}
return;
}
const restoreDir = await findNearestExistingDirectory(
path.dirname(stagedTarget.targetPath),
);
const restorePath = path.join(
restoreDir,
`.${path.basename(stagedTarget.targetPath)}.${randomUUID()}.rollback`,
);
await writeExclusiveFile(
restorePath,
stagedTarget.original,
stagedTarget.mode,
);
await fs.rename(restorePath, stagedTarget.targetPath);
}
async function stageInboxPatchTargets(
targets: AppliedSkillPatchTarget[],
): Promise<StagedInboxPatchTarget[]> {
const stagedTargets: StagedInboxPatchTarget[] = [];
try {
for (const target of targets) {
let mode: number | undefined;
if (!target.isNewFile) {
await fs.access(target.targetPath, fsConstants.W_OK);
mode = (await fs.stat(target.targetPath)).mode;
}
const tempDir = await findNearestExistingDirectory(
path.dirname(target.targetPath),
);
const tempPath = path.join(
tempDir,
`.${path.basename(target.targetPath)}.${randomUUID()}.patch-tmp`,
);
await writeExclusiveFile(tempPath, target.patched, mode);
stagedTargets.push({
targetPath: target.targetPath,
tempPath,
original: target.original,
isNewFile: target.isNewFile,
mode,
});
}
for (const target of stagedTargets) {
if (!target.isNewFile) {
continue;
}
await fs.mkdir(path.dirname(target.targetPath), { recursive: true });
}
return stagedTargets;
} catch (error) {
await cleanupStagedInboxPatchTargets(stagedTargets);
throw error;
}
}
/**
* Scans the skill extraction inbox for .patch files and returns
* structured data for each valid patch.
*/
export async function listInboxPatches(config: Config): Promise<InboxPatch[]> {
const skillsDir = config.storage.getProjectSkillsMemoryDir();
let entries: string[];
try {
entries = await fs.readdir(skillsDir);
} catch {
return [];
}
const patchFiles = entries.filter((e) => e.endsWith('.patch'));
if (patchFiles.length === 0) {
return [];
}
const patches: InboxPatch[] = [];
for (const patchFile of patchFiles) {
const patchPath = path.join(skillsDir, patchFile);
try {
const content = await fs.readFile(patchPath, 'utf-8');
const parsed = Diff.parsePatch(content);
if (!hasParsedPatchHunks(parsed)) continue;
const patchEntries: InboxPatchEntry[] = parsed.map((p) => ({
targetPath: p.newFileName ?? p.oldFileName ?? '',
diffContent: formatParsedDiff(p),
}));
patches.push({
fileName: patchFile,
name: patchFile.replace(/\.patch$/, ''),
entries: patchEntries,
extractedAt: await getPatchExtractedAt(patchPath),
});
} catch {
// Skip unreadable patch files
}
}
return patches;
}
/**
* Applies a .patch file from the inbox by reading each target file,
* applying the diff, and writing the result. Deletes the patch on success.
*/
export async function applyInboxPatch(
config: Config,
fileName: string,
): Promise<{ success: boolean; message: string }> {
if (!isValidInboxPatchFileName(fileName)) {
return {
success: false,
message: 'Invalid patch file name.',
};
}
const skillsDir = config.storage.getProjectSkillsMemoryDir();
const patchPath = path.join(skillsDir, fileName);
let content: string;
try {
content = await fs.readFile(patchPath, 'utf-8');
} catch {
return {
success: false,
message: `Patch "${fileName}" not found in inbox.`,
};
}
let parsed: Diff.StructuredPatch[];
try {
parsed = Diff.parsePatch(content);
} catch (error) {
return {
success: false,
message: `Failed to parse patch "${fileName}": ${getErrorMessage(error)}`,
};
}
if (!hasParsedPatchHunks(parsed)) {
return {
success: false,
message: `Patch "${fileName}" contains no valid hunks.`,
};
}
const validatedHeaders = validateParsedSkillPatchHeaders(parsed);
if (!validatedHeaders.success) {
return {
success: false,
message:
validatedHeaders.reason === 'missingTargetPath'
? `Patch "${fileName}" is missing a target file path.`
: `Patch "${fileName}" has invalid diff headers.`,
};
}
if (
!config.isTrustedFolder() &&
(await patchTargetsProjectSkills(
validatedHeaders.patches.map((patch) => patch.targetPath),
config,
))
) {
return {
success: false,
message:
'Project skill patches are unavailable until this workspace is trusted.',
};
}
// Dry-run first: verify all patches apply cleanly before writing anything.
// Repeated file blocks are validated against the progressively patched content.
const applied = await applyParsedSkillPatches(parsed, config);
if (!applied.success) {
switch (applied.reason) {
case 'missingTargetPath':
return {
success: false,
message: `Patch "${fileName}" is missing a target file path.`,
};
case 'invalidPatchHeaders':
return {
success: false,
message: `Patch "${fileName}" has invalid diff headers.`,
};
case 'outsideAllowedRoots':
return {
success: false,
message: `Patch "${fileName}" targets a file outside the global/workspace skill directories: ${applied.targetPath}`,
};
case 'newFileAlreadyExists':
return {
success: false,
message: `Patch "${fileName}" declares a new file, but the target already exists: ${applied.targetPath}`,
};
case 'targetNotFound':
return {
success: false,
message: `Target file not found: ${applied.targetPath}`,
};
case 'doesNotApply':
return {
success: false,
message: applied.isNewFile
? `Patch "${fileName}" failed to apply for new file ${applied.targetPath}.`
: `Patch does not apply cleanly to ${applied.targetPath}.`,
};
default:
return {
success: false,
message: `Patch "${fileName}" could not be applied.`,
};
}
}
let stagedTargets: StagedInboxPatchTarget[];
try {
stagedTargets = await stageInboxPatchTargets(applied.results);
} catch (error) {
return {
success: false,
message: `Patch "${fileName}" could not be staged: ${getErrorMessage(error)}.`,
};
}
const committedTargets: StagedInboxPatchTarget[] = [];
try {
for (const stagedTarget of stagedTargets) {
await fs.rename(stagedTarget.tempPath, stagedTarget.targetPath);
committedTargets.push(stagedTarget);
}
} catch (error) {
for (const committedTarget of committedTargets.reverse()) {
try {
await restoreCommittedInboxPatchTarget(committedTarget);
} catch {
// Best-effort rollback. We still report the commit failure below.
}
}
await cleanupStagedInboxPatchTargets(
stagedTargets.filter((target) => !committedTargets.includes(target)),
);
return {
success: false,
message: `Patch "${fileName}" could not be applied atomically: ${getErrorMessage(error)}.`,
};
}
// Remove the patch file
await fs.unlink(patchPath);
const fileCount = applied.results.length;
return {
success: true,
message: `Applied patch to ${fileCount} file${fileCount !== 1 ? 's' : ''}.`,
};
}
/**
* Removes a .patch file from the extraction inbox.
*/
export async function dismissInboxPatch(
config: Config,
fileName: string,
): Promise<{ success: boolean; message: string }> {
if (!isValidInboxPatchFileName(fileName)) {
return {
success: false,
message: 'Invalid patch file name.',
};
}
const skillsDir = config.storage.getProjectSkillsMemoryDir();
const patchPath = path.join(skillsDir, fileName);
try {
await fs.access(patchPath);
} catch {
return {
success: false,
message: `Patch "${fileName}" not found in inbox.`,
};
}
await fs.unlink(patchPath);
return {
success: true,
message: `Dismissed "${fileName}" from inbox.`,
};
}
+72
View File
@@ -3006,6 +3006,78 @@ describe('Config Quota & Preview Model Access', () => {
// Never set => stays null (unknown); getter returns true so UI shows preview
expect(config.getHasAccessToPreviewModel()).toBe(true);
});
it('should derive quota from remainingFraction when remainingAmount is missing', async () => {
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
buckets: [
{
modelId: 'gemini-3-flash-preview',
remainingFraction: 0.96,
},
],
});
config.setModel('gemini-3-flash-preview');
mockCoreEvents.emitQuotaChanged.mockClear();
await config.refreshUserQuota();
// Normalized: limit=100, remaining=96
expect(mockCoreEvents.emitQuotaChanged).toHaveBeenCalledWith(
96,
100,
undefined,
);
expect(config.getQuotaRemaining()).toBe(96);
expect(config.getQuotaLimit()).toBe(100);
});
it('should store quota from remainingFraction when remainingFraction is 0', async () => {
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
buckets: [
{
modelId: 'gemini-3-pro-preview',
remainingFraction: 0,
},
],
});
config.setModel('gemini-3-pro-preview');
mockCoreEvents.emitQuotaChanged.mockClear();
await config.refreshUserQuota();
// remaining=0, limit=100 but limit>0 check still passes
// however remaining=0 means 0% remaining = 100% used
expect(config.getQuotaRemaining()).toBe(0);
expect(config.getQuotaLimit()).toBe(100);
});
it('should emit QuotaChanged when model is switched via setModel', async () => {
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
buckets: [
{
modelId: 'gemini-2.5-pro',
remainingAmount: '10',
remainingFraction: 0.2,
},
{
modelId: 'gemini-2.5-flash',
remainingAmount: '80',
remainingFraction: 0.8,
},
],
});
config.setModel('auto-gemini-2.5');
await config.refreshUserQuota();
mockCoreEvents.emitQuotaChanged.mockClear();
// Switch to a specific model — should re-emit quota for that model
config.setModel('gemini-2.5-pro');
expect(mockCoreEvents.emitQuotaChanged).toHaveBeenCalledWith(
10,
50,
undefined,
);
});
});
describe('refreshUserQuotaIfStale', () => {
+40 -24
View File
@@ -699,6 +699,7 @@ export interface ConfigParameters {
experimentalJitContext?: boolean;
autoDistillation?: boolean;
experimentalMemoryManager?: boolean;
experimentalContextManagementConfig?: string;
experimentalAgentHistoryTruncation?: boolean;
experimentalAgentHistoryTruncationThreshold?: number;
experimentalAgentHistoryRetainedMessages?: number;
@@ -832,18 +833,16 @@ export class Config implements McpContext, AgentLoopContext {
private lastEmittedQuotaLimit: number | undefined;
private emitQuotaChangedEvent(): void {
const pooled = this.getPooledQuota();
const remaining = this.getQuotaRemaining();
const limit = this.getQuotaLimit();
const resetTime = this.getQuotaResetTime();
if (
this.lastEmittedQuotaRemaining !== pooled.remaining ||
this.lastEmittedQuotaLimit !== pooled.limit
this.lastEmittedQuotaRemaining !== remaining ||
this.lastEmittedQuotaLimit !== limit
) {
this.lastEmittedQuotaRemaining = pooled.remaining;
this.lastEmittedQuotaLimit = pooled.limit;
coreEvents.emitQuotaChanged(
pooled.remaining,
pooled.limit,
pooled.resetTime,
);
this.lastEmittedQuotaRemaining = remaining;
this.lastEmittedQuotaLimit = limit;
coreEvents.emitQuotaChanged(remaining, limit, resetTime);
}
}
@@ -941,6 +940,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly adminSkillsEnabled: boolean;
private readonly experimentalJitContext: boolean;
private readonly experimentalMemoryManager: boolean;
private readonly experimentalContextManagementConfig?: string;
private readonly memoryBoundaryMarkers: readonly string[];
private readonly topicUpdateNarration: boolean;
private readonly disableLLMCorrection: boolean;
@@ -1152,6 +1152,8 @@ export class Config implements McpContext, AgentLoopContext {
this.experimentalJitContext = params.experimentalJitContext ?? false;
this.experimentalMemoryManager = params.experimentalMemoryManager ?? false;
this.experimentalContextManagementConfig =
params.experimentalContextManagementConfig;
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
this.contextManagement = {
enabled: params.contextManagement?.enabled ?? false,
@@ -1819,6 +1821,9 @@ export class Config implements McpContext, AgentLoopContext {
// When the user explicitly sets a model, that becomes the active model.
this._activeModel = newModel;
coreEvents.emitModelChanged(newModel);
this.lastEmittedQuotaRemaining = undefined;
this.lastEmittedQuotaLimit = undefined;
this.emitQuotaChangedEvent();
}
if (this.onModelChange && !isTemporary) {
this.onModelChange(newModel);
@@ -2112,24 +2117,31 @@ export class Config implements McpContext, AgentLoopContext {
this.lastQuotaFetchTime = Date.now();
for (const bucket of quota.buckets) {
if (
bucket.modelId &&
bucket.remainingAmount &&
bucket.remainingFraction != null
) {
const remaining = parseInt(bucket.remainingAmount, 10);
const limit =
if (!bucket.modelId || bucket.remainingFraction == null) {
continue;
}
let remaining: number;
let limit: number;
if (bucket.remainingAmount) {
remaining = parseInt(bucket.remainingAmount, 10);
limit =
bucket.remainingFraction > 0
? Math.round(remaining / bucket.remainingFraction)
: (this.modelQuotas.get(bucket.modelId)?.limit ?? 0);
} else {
// Server only sent remainingFraction — use a normalized scale.
limit = 100;
remaining = Math.round(bucket.remainingFraction * limit);
}
if (!isNaN(remaining) && Number.isFinite(limit) && limit > 0) {
this.modelQuotas.set(bucket.modelId, {
remaining,
limit,
resetTime: bucket.resetTime,
});
}
if (!isNaN(remaining) && Number.isFinite(limit) && limit > 0) {
this.modelQuotas.set(bucket.modelId, {
remaining,
limit,
resetTime: bucket.resetTime,
});
}
}
this.emitQuotaChangedEvent();
@@ -2426,6 +2438,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.experimentalMemoryManager;
}
getExperimentalContextManagementConfig(): string | undefined {
return this.experimentalContextManagementConfig;
}
getContextManagementConfig(): ContextManagementConfig {
return this.contextManagement;
}
@@ -0,0 +1,94 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { loadContextManagementConfig } from './configLoader.js';
import { defaultContextProfile } from './profiles.js';
import { ContextProcessorRegistry } from './registry.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import type { Config } from '../../config/config.js';
import type { JSONSchemaType } from 'ajv';
describe('SidecarLoader (Real FS)', () => {
let tmpDir: string;
let registry: ContextProcessorRegistry;
let sidecarPath: string;
let mockConfig: Config;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-sidecar-test-'));
sidecarPath = path.join(tmpDir, 'sidecar.json');
registry = new ContextProcessorRegistry();
registry.registerProcessor({
id: 'NodeTruncation',
schema: {
type: 'object',
properties: { maxTokens: { type: 'number' } },
required: ['maxTokens'],
} as unknown as JSONSchemaType<{ maxTokens: number }>,
});
mockConfig = {
getExperimentalContextManagementConfig: () => sidecarPath,
} as unknown as Config;
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it('returns default profile if file does not exist', async () => {
const result = await loadContextManagementConfig(mockConfig, registry);
expect(result).toBe(defaultContextProfile);
});
it('returns default profile if file exists but is 0 bytes', async () => {
await fs.writeFile(sidecarPath, '');
const result = await loadContextManagementConfig(mockConfig, registry);
expect(result).toBe(defaultContextProfile);
});
it('returns parsed config if file is valid', async () => {
const validConfig = {
budget: { retainedTokens: 1000, maxTokens: 2000 },
processorOptions: {
myTruncation: {
type: 'NodeTruncation',
options: { maxTokens: 500 },
},
},
};
await fs.writeFile(sidecarPath, JSON.stringify(validConfig));
const result = await loadContextManagementConfig(mockConfig, registry);
expect(result.config.budget?.maxTokens).toBe(2000);
expect(result.config.processorOptions?.['myTruncation']).toBeDefined();
});
it('throws validation error if processorOptions contains invalid data for the schema', async () => {
const invalidConfig = {
budget: { retainedTokens: 1000, maxTokens: 2000 },
processorOptions: {
myTruncation: {
type: 'NodeTruncation',
options: { maxTokens: 'this should be a number' },
},
},
};
await fs.writeFile(sidecarPath, JSON.stringify(invalidConfig));
await expect(
loadContextManagementConfig(mockConfig, registry),
).rejects.toThrow('Validation error');
});
it('throws validation error if file is empty whitespace', async () => {
await fs.writeFile(sidecarPath, ' \n ');
await expect(
loadContextManagementConfig(mockConfig, registry),
).rejects.toThrow('Unexpected end of JSON input');
});
});
@@ -0,0 +1,90 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '../../config/config.js';
import * as fsSync from 'node:fs';
import * as fs from 'node:fs/promises';
import type { ContextManagementConfig } from './types.js';
import { defaultContextProfile, type ContextProfile } from './profiles.js';
import { SchemaValidator } from '../../utils/schemaValidator.js';
import { getContextManagementConfigSchema } from './schema.js';
import type { ContextProcessorRegistry } from './registry.js';
import { getErrorMessage } from '../../utils/errors.js';
/**
* 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.
*/
async function loadConfigFromFile(
sidecarPath: string,
registry: ContextProcessorRegistry,
): Promise<ContextProfile> {
const fileContent = await fs.readFile(sidecarPath, 'utf8');
let parsed: unknown;
try {
parsed = JSON.parse(fileContent);
} catch (error) {
throw new Error(
`Failed to parse Sidecar configuration file at ${sidecarPath}: ${getErrorMessage(
error,
)}`,
);
}
// Validate the complete structure, including deep options
const validationError = SchemaValidator.validate(
getContextManagementConfigSchema(registry),
parsed,
);
if (validationError) {
throw new Error(
`Invalid sidecar configuration in ${sidecarPath}. Validation error: ${validationError}`,
);
}
// Extract strictly what we need.
// Why this unsafe cast is acceptable:
// SchemaValidator just ran \`getSidecarConfigSchema(registry)\` against \`parsed\`.
// That function dynamically maps the \`processorOptions\` to strict JSON schema definitions,
// so we know with absolute certainty at runtime that \`parsed\` conforms to this shape.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const validConfig = parsed as ContextManagementConfig;
return {
...defaultContextProfile,
config: {
...defaultContextProfile.config,
...(validConfig.budget ? { budget: validConfig.budget } : {}),
...(validConfig.processorOptions
? { processorOptions: validConfig.processorOptions }
: {}),
},
};
}
/**
* 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.
*/
export async function loadContextManagementConfig(
config: Config,
registry: ContextProcessorRegistry,
): Promise<ContextProfile> {
const sidecarPath = config.getExperimentalContextManagementConfig();
if (sidecarPath && fsSync.existsSync(sidecarPath)) {
const size = fsSync.statSync(sidecarPath).size;
// If the file exists but is completely empty (0 bytes), it's safe to fallback.
if (size === 0) {
return defaultContextProfile;
}
// If the file has content, enforce strict validation and throw on failure.
return loadConfigFromFile(sidecarPath, registry);
}
return defaultContextProfile;
}
@@ -0,0 +1,145 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
AsyncPipelineDef,
ContextManagementConfig,
PipelineDef,
} from './types.js';
import type { ContextEnvironment } from '../pipeline/environment.js';
// Import factories
import { createToolMaskingProcessor } from '../processors/toolMaskingProcessor.js';
import { createBlobDegradationProcessor } from '../processors/blobDegradationProcessor.js';
import { createNodeTruncationProcessor } from '../processors/nodeTruncationProcessor.js';
import { createNodeDistillationProcessor } from '../processors/nodeDistillationProcessor.js';
import { createStateSnapshotProcessor } from '../processors/stateSnapshotProcessor.js';
import { createStateSnapshotAsyncProcessor } from '../processors/stateSnapshotAsyncProcessor.js';
/**
* Helper to safely merge static default options with dynamically loaded
* JSON overrides from the SidecarConfig.
*
* Why the unsafe cast is acceptable here:
* Before the \`config\` object ever reaches this function, \`SidecarLoader.ts\`
* passes the raw JSON through \`SchemaValidator\`. The schema dynamically generates
* a \`oneOf\` map linking every \`type\` discriminator to its corresponding processor
* schema definition. By the time we access \`options\` here, its shape has been
* strictly validated against the corresponding Zod/JSONSchema definition at runtime,
* making the generic cast to \`<T>\` structurally safe.
*/
function resolveProcessorOptions<T>(
config: ContextManagementConfig | undefined,
id: string,
defaultOptions: T,
): T {
if (config?.processorOptions && config.processorOptions[id]) {
return {
...defaultOptions,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(config.processorOptions[id].options as T),
};
}
return defaultOptions;
}
export interface ContextProfile {
config: ContextManagementConfig;
buildPipelines: (
env: ContextEnvironment,
config?: ContextManagementConfig,
) => PipelineDef[];
buildAsyncPipelines: (
env: ContextEnvironment,
config?: ContextManagementConfig,
) => AsyncPipelineDef[];
}
/**
* The standard default context management profile.
* Optimized for safety, precision, and reliable summarization.
*/
export const defaultContextProfile: ContextProfile = {
config: {
budget: {
retainedTokens: 65000,
maxTokens: 150000,
},
},
buildPipelines: (
env: ContextEnvironment,
config?: ContextManagementConfig,
): PipelineDef[] =>
// Helper to merge default options with dynamically loaded processorOptions by ID
[
{
name: 'Immediate Sanitization',
triggers: ['new_message'],
processors: [
createToolMaskingProcessor(
'ToolMasking',
env,
resolveProcessorOptions(config, 'ToolMasking', {
stringLengthThresholdTokens: 8000,
}),
),
createBlobDegradationProcessor('BlobDegradation', env), // No options
],
},
{
name: 'Normalization',
triggers: ['retained_exceeded'],
processors: [
createNodeTruncationProcessor(
'NodeTruncation',
env,
resolveProcessorOptions(config, 'NodeTruncation', {
maxTokensPerNode: 3000,
}),
),
createNodeDistillationProcessor(
'NodeDistillation',
env,
resolveProcessorOptions(config, 'NodeDistillation', {
nodeThresholdTokens: 5000,
}),
),
],
},
{
name: 'Emergency Backstop',
triggers: ['gc_backstop'],
processors: [
createStateSnapshotProcessor(
'StateSnapshotSync',
env,
resolveProcessorOptions(config, 'StateSnapshotSync', {
target: 'max',
}),
),
],
},
],
buildAsyncPipelines: (
env: ContextEnvironment,
config?: ContextManagementConfig,
): AsyncPipelineDef[] => [
{
name: 'Async Background GC',
triggers: ['nodes_aged_out'],
processors: [
createStateSnapshotAsyncProcessor(
'StateSnapshotAsync',
env,
resolveProcessorOptions(config, 'StateSnapshotAsync', {
type: 'accumulate',
}),
),
],
},
],
};
@@ -0,0 +1,42 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { JSONSchemaType } from 'ajv';
export interface ContextProcessorDef<T = unknown> {
readonly id: string;
readonly schema: JSONSchemaType<T>;
}
/**
* Registry for validating declarative sidecar configuration schemas.
* (Dynamic instantiation has been replaced by static ContextProfiles)
*/
export class ContextProcessorRegistry {
private readonly processors = new Map<string, ContextProcessorDef>();
registerProcessor<T>(def: ContextProcessorDef<T>) {
// Erasing the type.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
this.processors.set(def.id, def as unknown as ContextProcessorDef<unknown>);
}
getSchema(id: string): object | undefined {
return this.processors.get(id)?.schema;
}
getSchemaDefs(): ContextProcessorDef[] {
const defs = [];
for (const def of this.processors.values()) {
if (def.schema) defs.push({ id: def.id, schema: def.schema });
}
return defs;
}
clear() {
this.processors.clear();
}
}
@@ -0,0 +1,55 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ContextProcessorRegistry } from './registry.js';
export function getContextManagementConfigSchema(
registry: ContextProcessorRegistry,
) {
// We use a registry to deeply validate processor overrides.
// We do this by generating a `oneOf` list that matches the `type` discriminator
// to the specific processor `options` schema.
const processorOptionSchemas = registry.getSchemaDefs().map((def) => ({
type: 'object',
required: ['type', 'options'],
properties: {
type: { const: def.id },
options: def.schema,
},
}));
return {
$schema: 'http://json-schema.org/draft-07/schema#',
title: 'ContextManagementConfig',
description: 'The Hyperparameter schema for a Context Profile.',
type: 'object',
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.',
},
},
},
processorOptions: {
type: 'object',
description:
'Named hyperparameter configurations for ContextProcessors and AsyncProcessors.',
additionalProperties: { oneOf: processorOptionSchemas },
},
},
};
}
+46
View File
@@ -0,0 +1,46 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ContextProcessor, AsyncContextProcessor } from '../pipeline.js';
export type PipelineTrigger =
| 'new_message'
| 'retained_exceeded'
| 'gc_backstop'
| 'nodes_added'
| 'nodes_aged_out'
| { type: 'timer'; intervalMs: number };
export interface PipelineDef {
name: string;
triggers: PipelineTrigger[];
processors: ContextProcessor[];
}
export interface AsyncPipelineDef {
name: string;
triggers: PipelineTrigger[];
processors: AsyncContextProcessor[];
}
export interface ContextBudget {
retainedTokens: number;
maxTokens: number;
}
/**
* The Data-Driven Schema for the Context Manager.
*/
export interface ContextManagementConfig {
/** Defines the token ceilings and limits for the pipeline. */
budget: ContextBudget;
/**
* Dynamic hyperparameter overrides for individual ContextProcessors and AsyncProcessors.
* Keys are named identifiers (e.g. "gentleTruncation").
*/
processorOptions?: Record<string, { type: string; options: unknown }>;
}
@@ -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.renderHistory();
// 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.');
});
});
+170
View File
@@ -0,0 +1,170 @@
/**
* @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 './graph/types.js';
import type { ContextEventBus } from './eventBus.js';
import type { ContextTracer } from './tracer.js';
import type { ContextEnvironment } from './pipeline/environment.js';
import type { ContextProfile } from './config/profiles.js';
import type { PipelineOrchestrator } from './pipeline/orchestrator.js';
import { HistoryObserver } from './historyObserver.js';
import { render } from './graph/render.js';
import { ContextWorkingBufferImpl } from './pipeline/contextWorkingBuffer.js';
export class ContextManager {
// The master state containing the pristine graph and current active graph.
private buffer: ContextWorkingBufferImpl =
ContextWorkingBufferImpl.initialize([]);
private readonly eventBus: ContextEventBus;
// Internal sub-components
private readonly orchestrator: PipelineOrchestrator;
private readonly historyObserver: HistoryObserver;
constructor(
private readonly sidecar: ContextProfile,
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.graphMapper,
);
this.historyObserver.start();
this.eventBus.onPristineHistoryUpdated((event) => {
const existingIds = new Set(this.buffer.nodes.map((n) => n.id));
const newIds = new Set(event.nodes.map((n) => n.id));
const addedNodes = event.nodes.filter((n) => !existingIds.has(n.id));
// Prune any pristine nodes that were dropped from the upstream history
this.buffer = this.buffer.prunePristineNodes(newIds);
if (addedNodes.length > 0) {
this.buffer = this.buffer.appendPristineNodes(addedNodes);
}
this.evaluateTriggers(event.newNodes);
});
}
/**
* Safely stops background async pipelines 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.config.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.config.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.config.budget.retainedTokens) {
agedOutNodes.add(node.id);
}
}
if (agedOutNodes.size > 0) {
this.env.tokenCalculator.garbageCollectCache(
new Set(this.buffer.nodes.map((n) => n.id)),
);
this.eventBus.emitConsolidationNeeded({
nodes: this.buffer.nodes,
targetDeficit:
currentTokens - this.sidecar.config.budget.retainedTokens,
targetNodeIds: agedOutNodes,
});
}
}
}
/**
* Retrieves the raw, uncompressed Episodic Context Graph graph.
* Useful for internal tool rendering (like the trace viewer).
* Note: This is an expensive, deep clone operation.
*/
getPristineGraph(): readonly ConcreteNode[] {
const pristineSet = new Map<string, ConcreteNode>();
for (const node of this.buffer.nodes) {
const roots = this.buffer.getPristineNodes(node.id);
for (const root of roots) {
pristineSet.set(root.id, root);
}
}
// We sort them by timestamp to ensure they are returned in chronological order
return Array.from(pristineSet.values()).sort(
(a, b) => a.timestamp - b.timestamp,
);
}
/**
* 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 Context Graph back into a raw Gemini Content[] array for transmission.
* This is the primary method called by the agent framework before sending a request.
*/
async renderHistory(
activeTaskIds: Set<string> = new Set(),
): Promise<Content[]> {
this.tracer.logEvent('ContextManager', 'Starting rendering of LLM context');
// Apply final GC Backstop pressure barrier synchronously before mapping
const finalHistory = await render(
this.buffer.nodes,
this.orchestrator,
this.sidecar,
this.tracer,
this.env,
activeTaskIds,
);
this.tracer.logEvent('ContextManager', 'Finished rendering');
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 './graph/types.js';
export interface PristineHistoryUpdatedEvent {
nodes: readonly ConcreteNode[];
newNodes: Set<string>;
}
export interface ContextConsolidationEvent {
nodes: readonly ConcreteNode[];
targetDeficit: number;
targetNodeIds: Set<string>;
}
export interface ChunkReceivedEvent {
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: ChunkReceivedEvent) {
this.emit('IR_CHUNK_RECEIVED', event);
}
onChunkReceived(listener: (event: ChunkReceivedEvent) => 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,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 NodeSerializationWriter {
appendContent(content: Content): void;
appendModelPart(part: Part): void;
appendUserPart(part: Part): void;
flushModelParts(): void;
}
export interface NodeBehavior<T extends ConcreteNode = ConcreteNode> {
readonly type: T['type'];
/** Serializes the node into the Gemini Content structure. */
serialize(node: T, writer: NodeSerializationWriter): void;
/**
* Generates a structural representation of the node for the purpose
* of estimating its token cost.
*/
getEstimatableParts(node: T): Part[];
}
export class NodeBehaviorRegistry {
private readonly behaviors = new Map<string, NodeBehavior<ConcreteNode>>();
register<T extends ConcreteNode>(behavior: NodeBehavior<T>) {
this.behaviors.set(behavior.type, behavior);
}
get(type: string): NodeBehavior<ConcreteNode> {
const behavior = this.behaviors.get(type);
if (!behavior) {
throw new Error(`Unregistered Node type: ${type}`);
}
return behavior;
}
}
@@ -0,0 +1,172 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Part } from '@google/genai';
import type { NodeBehavior, NodeBehaviorRegistry } from './behaviorRegistry.js';
import type {
UserPrompt,
AgentThought,
ToolExecution,
MaskedTool,
AgentYield,
Snapshot,
RollingSummary,
SystemEvent,
} from './types.js';
export const UserPromptBehavior: NodeBehavior<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: NodeBehavior<AgentThought> = {
type: 'AGENT_THOUGHT',
getEstimatableParts(thought) {
return [{ text: thought.text }];
},
serialize(thought, writer) {
writer.appendModelPart({ text: thought.text });
},
};
export const ToolExecutionBehavior: NodeBehavior<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: NodeBehavior<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: NodeBehavior<AgentYield> = {
type: 'AGENT_YIELD',
getEstimatableParts(yieldNode) {
return [{ text: yieldNode.text }];
},
serialize(yieldNode, writer) {
writer.appendModelPart({ text: yieldNode.text });
writer.flushModelParts();
},
};
export const SystemEventBehavior: NodeBehavior<SystemEvent> = {
type: 'SYSTEM_EVENT',
getEstimatableParts() {
return [];
},
serialize(node, writer) {
writer.flushModelParts();
},
};
export const SnapshotBehavior: NodeBehavior<Snapshot> = {
type: 'SNAPSHOT',
getEstimatableParts(node) {
return [{ text: node.text }];
},
serialize(node, writer) {
writer.flushModelParts();
writer.appendUserPart({ text: node.text });
},
};
export const RollingSummaryBehavior: NodeBehavior<RollingSummary> = {
type: 'ROLLING_SUMMARY',
getEstimatableParts(node) {
return [{ text: node.text }];
},
serialize(node, writer) {
writer.flushModelParts();
writer.appendUserPart({ text: node.text });
},
};
export function registerBuiltInBehaviors(registry: NodeBehaviorRegistry) {
registry.register(UserPromptBehavior);
registry.register(AgentThoughtBehavior);
registry.register(ToolExecutionBehavior);
registry.register(MaskedToolBehavior);
registry.register(AgentYieldBehavior);
registry.register(SystemEventBehavior);
registry.register(SnapshotBehavior);
registry.register(RollingSummaryBehavior);
}
@@ -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 {
NodeSerializationWriter,
NodeBehaviorRegistry,
} from './behaviorRegistry.js';
class NodeSerializer implements NodeSerializationWriter {
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 fromGraph(
nodes: readonly ConcreteNode[],
registry: NodeBehaviorRegistry,
): Content[] {
const writer = new NodeSerializer();
for (const node of nodes) {
const behavior = registry.get(node.type);
behavior.serialize(node, writer);
}
return writer.getContents();
}
+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 { toGraph } from './toGraph.js';
import { fromGraph } from './fromGraph.js';
import type { ContextTokenCalculator } from '../utils/contextTokenCalculator.js';
import type { NodeBehaviorRegistry } from './behaviorRegistry.js';
export class ContextGraphMapper {
private readonly nodeIdentityMap = new WeakMap<object, string>();
constructor(private readonly registry: NodeBehaviorRegistry) {}
toGraph(
history: readonly Content[],
tokenCalculator: ContextTokenCalculator,
): Episode[] {
return toGraph(history, tokenCalculator, this.nodeIdentityMap);
}
fromGraph(nodes: readonly ConcreteNode[]): Content[] {
return fromGraph(nodes, this.registry);
}
}
+115
View File
@@ -0,0 +1,115 @@
/**
* @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 '../pipeline/environment.js';
import type { PipelineOrchestrator } from '../pipeline/orchestrator.js';
import type { ContextProfile } from '../config/profiles.js';
/**
* Orchestrates the final render: takes a working buffer view (The Nodes),
* applies the Immediate Sanitization pipeline, and enforces token boundaries.
*/
export async function render(
nodes: readonly ConcreteNode[],
orchestrator: PipelineOrchestrator,
sidecar: ContextProfile,
tracer: ContextTracer,
env: ContextEnvironment,
protectedIds: Set<string>,
): Promise<Content[]> {
if (!sidecar.config.budget) {
const contents = env.graphMapper.fromGraph(nodes);
tracer.logEvent('Render', 'Render Context to LLM (No Budget)', {
renderedContext: contents,
});
return contents;
}
const maxTokens = sidecar.config.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(
'Render',
`View is within maxTokens (${currentTokens} <= ${maxTokens}). Returning view.`,
);
const contents = env.graphMapper.fromGraph(nodes);
tracer.logEvent('Render', 'Render Context for LLM', {
renderedContext: contents,
});
return contents;
}
tracer.logEvent(
'Render',
`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.config.budget.retainedTokens) {
agedOutNodes.add(node.id);
}
}
const processedNodes = await orchestrator.executeTriggerSync(
'gc_backstop',
nodes,
agedOutNodes,
protectedIds,
);
const finalTokens =
env.tokenCalculator.calculateConcreteListTokens(processedNodes);
tracer.logEvent(
'Render',
`Finished rendering. 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.graphMapper.fromGraph(visibleNodes);
tracer.logEvent('Render', 'Render Sanitized Context for LLM', {
renderedContextSanitized: 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 type {
Episode,
SemanticPart,
ToolExecution,
AgentThought,
AgentYield,
UserPrompt,
} from './types.js';
import type { ContextTokenCalculator } from '../utils/contextTokenCalculator.js';
import { randomUUID } from 'node:crypto';
import { isRecord } from '../../utils/markdownUtils.js';
// We remove the global nodeIdentityMap and instead rely on one passed from ContextGraphMapper
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 isCompleteEpisode(ep: Partial<Episode>): ep is Episode {
return (
typeof ep.id === 'string' &&
Array.isArray(ep.concreteNodes) &&
ep.concreteNodes.length > 0
);
}
export function toGraph(
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),
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),
timestamp: Date.now(),
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),
timestamp: Date.now(),
type: 'USER_PROMPT',
semanticParts,
};
return {
id: getStableId(msg, nodeIdentityMap),
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),
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),
timestamp: Date.now(),
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(),
timestamp: Date.now(),
type: 'AGENT_YIELD',
text: 'Yield', // Synthesized yield since we don't have the original concrete node
};
const existingNodes = currentEpisode.concreteNodes || [];
currentEpisode.concreteNodes = [...existingNodes, yieldNode];
}
}
+222
View File
@@ -0,0 +1,222 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Part } from '@google/genai';
export type NodeType =
// 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 Context Graph */
export interface Node {
readonly id: string;
readonly type: NodeType;
}
/**
* Concrete Nodes: The atomic, renderable pieces of data.
* These are the actual "planks" of the Nodes of Theseus.
*/
export interface BaseConcreteNode extends Node {
readonly timestamp: number;
/** 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 interface SemanticTextPart {
readonly type: 'text';
readonly text: string;
}
export interface SemanticInlineDataPart {
readonly type: 'inline_data';
readonly mimeType: string;
readonly data: string;
}
export interface SemanticFileDataPart {
readonly type: 'file_data';
readonly mimeType: string;
readonly fileUri: string;
}
export interface SemanticRawPart {
readonly type: 'raw_part';
readonly part: Part;
}
export type SemanticPart =
| SemanticTextPart
| SemanticInlineDataPart
| SemanticFileDataPart
| SemanticRawPart;
/**
* 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 text: string;
}
export interface RollingSummary extends BaseConcreteNode {
readonly type: 'ROLLING_SUMMARY';
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 Node {
readonly type: 'EPISODE';
/** References to the Concrete Node IDs that conceptually belong to this Episode. */
concreteNodes: readonly ConcreteNode[];
}
export interface Task extends Node {
readonly type: 'TASK';
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;
export function isEpisode(node: Node): node is Episode {
return node.type === 'EPISODE';
}
export function isTask(node: Node): node is Task {
return node.type === 'TASK';
}
export function isAgentThought(node: Node): node is AgentThought {
return node.type === 'AGENT_THOUGHT';
}
export function isAgentYield(node: Node): node is AgentYield {
return node.type === 'AGENT_YIELD';
}
export function isToolExecution(node: Node): node is ToolExecution {
return node.type === 'TOOL_EXECUTION';
}
export function isMaskedTool(node: Node): node is MaskedTool {
return node.type === 'MASKED_TOOL';
}
export function isUserPrompt(node: Node): node is UserPrompt {
return node.type === 'USER_PROMPT';
}
export function isSystemEvent(node: Node): node is SystemEvent {
return node.type === 'SYSTEM_EVENT';
}
export function isSnapshot(node: Node): node is Snapshot {
return node.type === 'SNAPSHOT';
}
export function isRollingSummary(node: Node): node is RollingSummary {
return node.type === 'ROLLING_SUMMARY';
}
@@ -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 { ContextGraphMapper } from './graph/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 './graph/types.js';
/**
* Connects the raw AgentChatHistory to the ContextManager.
* It maps raw messages into Episodic Intermediate Representation (Context Graph)
* and evaluates background triggers whenever history changes.
*/
export class HistoryObserver {
private unsubscribeHistory?: () => void;
private readonly seenNodeIds = new Set<string>();
constructor(
private readonly chatHistory: AgentChatHistory,
private readonly eventBus: ContextEventBus,
private readonly tracer: ContextTracer,
private readonly tokenCalculator: ContextTokenCalculator,
private readonly graphMapper: ContextGraphMapper,
) {}
start() {
if (this.unsubscribeHistory) {
this.unsubscribeHistory();
}
this.unsubscribeHistory = this.chatHistory.subscribe(
(_event: HistoryEvent) => {
// Rebuild the pristine Context Graph graph from the full source history on every change.
// Wait, toGraph still returns an Episode[].
// We actually need to map the Episode[] to a flat ConcreteNode[] here to form the 'nodes'.
const pristineEpisodes = this.graphMapper.toGraph(
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;
}
}
}
+61
View File
@@ -0,0 +1,61 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ConcreteNode } from './graph/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;
}
/**
* A ContextProcessor is a pure, closure-based object that returns a modified subset of nodes
* (or the original targets if no changes are needed).
* The Orchestrator will use this to generate a new graph delta.
*/
export interface ContextProcessor {
readonly id: string;
readonly name: string;
process(args: ProcessArgs): Promise<readonly ConcreteNode[]>;
}
export interface AsyncContextProcessor {
readonly id: string;
readonly name: string;
process(args: ProcessArgs): Promise<void>;
}
export interface BackstopTargetOptions {
target?: 'incremental' | 'freeNTokens' | 'max';
freeTokensTarget?: number;
}
@@ -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,270 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ContextWorkingBuffer, GraphMutation } from '../pipeline.js';
import type { ConcreteNode } from '../graph/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);
}
// GC the Caches
// We only want to keep provenance and pristine entries that are reachable
// from the nodes in 'newGraph'.
const reachablePristineIds = new Set<string>();
const reachableCurrentIds = new Set<string>();
for (const node of newGraph) {
reachableCurrentIds.add(node.id);
const roots = newProvenanceMap.get(node.id);
if (roots) {
for (const root of roots) {
reachablePristineIds.add(root);
}
}
}
// Prune Provenance Map
for (const [id] of newProvenanceMap) {
if (!reachableCurrentIds.has(id)) {
newProvenanceMap.delete(id);
}
}
// Prune Pristine Map
const prunedPristineMap = new Map<string, ConcreteNode>();
for (const id of reachablePristineIds) {
const node = finalPristineMap.get(id);
if (node) prunedPristineMap.set(id, node);
}
finalPristineMap = prunedPristineMap;
return new ContextWorkingBufferImpl(
newGraph,
finalPristineMap,
newProvenanceMap,
[...this.history, mutation],
);
}
/** Removes nodes from the working buffer that were completely dropped from the upstream pristine history */
prunePristineNodes(
retainedIds: ReadonlySet<string>,
): ContextWorkingBufferImpl {
const newGraph = this.nodes.filter(
(n) => retainedIds.has(n.id) || !this.pristineNodesMap.has(n.id),
);
const newProvenanceMap = new Map(this.provenanceMap);
const reachablePristineIds = new Set<string>();
const reachableCurrentIds = new Set<string>();
for (const node of newGraph) {
reachableCurrentIds.add(node.id);
const roots = newProvenanceMap.get(node.id);
if (roots) {
for (const root of roots) {
if (retainedIds.has(root) || !this.pristineNodesMap.has(root)) {
reachablePristineIds.add(root);
}
}
}
}
for (const [id] of newProvenanceMap) {
if (!reachableCurrentIds.has(id)) {
newProvenanceMap.delete(id);
}
}
const prunedPristineMap = new Map<string, ConcreteNode>();
for (const id of reachablePristineIds) {
const node = this.pristineNodesMap.get(id);
if (node) prunedPristineMap.set(id, node);
}
return new ContextWorkingBufferImpl(
newGraph,
prunedPristineMap,
newProvenanceMap,
[...this.history],
);
}
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,29 @@
/**
* @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 { LiveInbox } from './inbox.js';
import type { NodeBehaviorRegistry } from '../graph/behaviorRegistry.js';
import type { ContextGraphMapper } from '../graph/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 eventBus: ContextEventBus;
readonly inbox: LiveInbox;
readonly behaviorRegistry: NodeBehaviorRegistry;
readonly graphMapper: ContextGraphMapper;
}
@@ -0,0 +1,44 @@
/**
* @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 { 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.inbox).toBeDefined();
expect(env.graphMapper).toBeDefined();
});
});
@@ -0,0 +1,42 @@
/**
* @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 { LiveInbox } from './inbox.js';
import { NodeBehaviorRegistry } from '../graph/behaviorRegistry.js';
import { registerBuiltInBehaviors } from '../graph/builtinBehaviors.js';
import { ContextGraphMapper } from '../graph/mapper.js';
export class ContextEnvironmentImpl implements ContextEnvironment {
readonly tokenCalculator: ContextTokenCalculator;
readonly inbox: LiveInbox;
readonly behaviorRegistry: NodeBehaviorRegistry;
readonly graphMapper: ContextGraphMapper;
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,
) {
this.behaviorRegistry = new NodeBehaviorRegistry();
registerBuiltInBehaviors(this.behaviorRegistry);
this.tokenCalculator = new ContextTokenCalculator(
this.charsPerToken,
this.behaviorRegistry,
);
this.inbox = new LiveInbox();
this.graphMapper = new ContextGraphMapper(this.behaviorRegistry);
}
}
@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { LiveInbox, InboxSnapshotImpl } from './inbox.js';
describe('Inbox', () => {
it('should publish messages and provide snapshots', () => {
const inbox = new LiveInbox();
inbox.publish('test-topic', { data: 'hello' });
inbox.publish('other-topic', { data: 'world' });
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();
inbox.publish('test-topic', { data: 'hello' });
inbox.publish('other-topic', { data: 'world' });
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,61 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import type { InboxMessage, InboxSnapshot } from '../pipeline.js';
export class LiveInbox {
private messages: InboxMessage[] = [];
publish<T>(topic: string, payload: T): void {
this.messages.push({
id: randomUUID(),
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 async pipeline 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,224 @@
/**
* @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 {
createMockEnvironment,
createDummyNode,
} from '../testing/contextTestUtils.js';
import type { ContextEnvironment } from './environment.js';
import type {
ContextProcessor,
AsyncContextProcessor,
ProcessArgs,
} from '../pipeline.js';
import type { PipelineDef, AsyncPipelineDef } from '../config/types.js';
import type { ContextEventBus } from '../eventBus.js';
import type { ConcreteNode, UserPrompt } from '../graph/types.js';
// A realistic mock processor that modifies the text of the first target node
function createModifyingProcessor(id: string): ContextProcessor {
return {
id,
name: 'ModifyingProcessor',
process: async (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
function createThrowingProcessor(id: string): ContextProcessor {
return {
id,
name: 'Throwing',
process: async (): Promise<readonly ConcreteNode[]> => {
throw new Error('Processor failed intentionally');
},
};
}
// A mock async processor that signals it ran
function createMockAsyncProcessor(
id: string,
executeSpy: ReturnType<typeof vi.fn>,
): AsyncContextProcessor {
return {
id,
name: 'MockAsyncProcessor',
process: async (args: ProcessArgs) => {
executeSpy(args);
},
};
}
describe('PipelineOrchestrator (Component)', () => {
let env: ContextEnvironment;
let eventBus: ContextEventBus;
beforeEach(() => {
env = createMockEnvironment();
eventBus = env.eventBus;
});
afterEach(() => {
vi.restoreAllMocks();
});
const setupOrchestrator = (
pipelines: PipelineDef[],
asyncPipelines: AsyncPipelineDef[] = [],
) => {
const orchestrator = new PipelineOrchestrator(
pipelines,
asyncPipelines,
env,
eventBus,
env.tracer,
);
return orchestrator;
};
describe('Synchronous Pipeline Execution', () => {
it('applies processors in sequence on matching trigger', async () => {
const pipelines: PipelineDef[] = [
{
name: 'TestPipeline',
triggers: ['new_message'],
processors: [createModifyingProcessor('Mod')],
},
];
const orchestrator = setupOrchestrator(pipelines);
const originalNode = createDummyNode('ep1', 'USER_PROMPT', 50, {
semanticParts: [{ type: 'text', text: 'Original' }],
});
const processed = await orchestrator.executeTriggerSync(
'new_message',
[originalNode],
new Set([originalNode.id]),
new Set(),
);
expect(processed.length).toBe(1);
const resultingNode = processed[0] as UserPrompt;
assert(resultingNode.semanticParts[0].type === 'text');
expect(resultingNode.semanticParts[0].text).toBe('Original [modified]');
expect(resultingNode.replacesId).toBe(originalNode.id);
});
it('bypasses pipelines that do not match the trigger', async () => {
const pipelines: PipelineDef[] = [
{
name: 'TestPipeline',
triggers: ['gc_backstop'], // Different trigger
processors: [createModifyingProcessor('Mod')],
},
];
const orchestrator = setupOrchestrator(pipelines);
const originalNode = createDummyNode('ep1', 'USER_PROMPT', 50, {
semanticParts: [{ type: 'text', text: 'Original' }],
});
const processed = await orchestrator.executeTriggerSync(
'new_message',
[originalNode],
new Set([originalNode.id]),
new Set(),
);
expect(processed).toEqual([originalNode]); // Untouched
});
it('gracefully handles a failing processor without crashing the pipeline', async () => {
const pipelines: PipelineDef[] = [
{
name: 'FailingPipeline',
triggers: ['new_message'],
processors: [
createThrowingProcessor('Thrower'),
createModifyingProcessor('Mod'),
],
},
];
const orchestrator = setupOrchestrator(pipelines);
const originalNode = createDummyNode('ep1', 'USER_PROMPT', 50, {
semanticParts: [{ type: 'text', text: 'Original' }],
});
// The throwing processor should be caught and logged, allowing Mod to still run.
const processed = await orchestrator.executeTriggerSync(
'new_message',
[originalNode],
new Set([originalNode.id]),
new Set(),
);
expect(processed.length).toBe(1);
const resultingNode = processed[0] as UserPrompt;
assert(resultingNode.semanticParts[0].type === 'text');
expect(resultingNode.semanticParts[0].text).toBe('Original [modified]');
});
});
describe('Asynchronous async pipeline Events', () => {
it('routes emitChunkReceived to async pipelines with nodes_added trigger', async () => {
const executeSpy = vi.fn();
const asyncProcessor = createMockAsyncProcessor(
'MyAsyncProcessor',
executeSpy,
);
setupOrchestrator(
[],
[
{
name: 'TestAsync',
triggers: ['nodes_added'],
processors: [asyncProcessor],
},
],
);
const node1 = createDummyNode('ep1', 'USER_PROMPT', 10);
const node2 = createDummyNode('ep1', 'AGENT_THOUGHT', 20);
eventBus.emitChunkReceived({
nodes: [node1, node2],
targetNodeIds: new Set([node2.id]),
});
// Yield event loop
await new Promise((resolve) => setTimeout(resolve, 0));
expect(executeSpy).toHaveBeenCalledTimes(1);
const callArgs = executeSpy.mock.calls[0][0];
expect(callArgs.targets).toEqual([node2]); // AsyncProcessors only get the target nodes
});
});
});
@@ -0,0 +1,218 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ConcreteNode } from '../graph/types.js';
import type {
AsyncPipelineDef,
PipelineDef,
PipelineTrigger,
} from '../config/types.js';
import type {
ContextEnvironment,
ContextEventBus,
ContextTracer,
} from './environment.js';
import { debugLogger } from '../../utils/debugLogger.js';
import { InboxSnapshotImpl } from './inbox.js';
import { ContextWorkingBufferImpl } from './contextWorkingBuffer.js';
export class PipelineOrchestrator {
private activeTimers: NodeJS.Timeout[] = [];
constructor(
private readonly pipelines: PipelineDef[],
private readonly asyncPipelines: AsyncPipelineDef[],
private readonly env: ContextEnvironment,
private readonly eventBus: ContextEventBus,
private readonly tracer: ContextTracer,
) {
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 setupTriggers() {
const bindTriggers = <P extends PipelineDef | AsyncPipelineDef>(
pipelines: P[],
executeFn: (
pipeline: P,
nodes: readonly ConcreteNode[],
targets: ReadonlySet<string>,
protectedIds: ReadonlySet<string>,
) => void,
) => {
for (const pipeline of 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' ||
trigger === 'nodes_aged_out'
) {
this.eventBus.onConsolidationNeeded((event) => {
executeFn(pipeline, event.nodes, event.targetNodeIds, new Set());
});
} else if (trigger === 'new_message' || trigger === 'nodes_added') {
this.eventBus.onChunkReceived((event) => {
executeFn(pipeline, event.nodes, event.targetNodeIds, new Set());
});
}
}
}
};
bindTriggers(this.pipelines, (pipeline, nodes, targets, protectedIds) => {
void this.executePipelineAsync(
pipeline,
nodes,
new Set(targets),
new Set(protectedIds),
);
});
bindTriggers(this.asyncPipelines, (pipeline, nodes, targetIds) => {
const inboxSnapshot = new InboxSnapshotImpl(
this.env.inbox.getMessages() || [],
);
const targets = nodes.filter((n) => targetIds.has(n.id));
for (const processor of pipeline.processors) {
processor
.process({
targets,
inbox: inboxSnapshot,
buffer: ContextWorkingBufferImpl.initialize(nodes),
})
.catch((e: unknown) =>
debugLogger.error(`AsyncProcessor ${processor.name} failed:`, e),
);
}
});
}
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 triggerPipelines = this.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 triggerPipelines) {
for (const processor of pipeline.processors) {
try {
this.tracer.logEvent(
'Orchestrator',
`Executing processor synchronously: ${processor.id}`,
);
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 ${processor.id} 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 processor of pipeline.processors) {
try {
this.tracer.logEvent(
'Orchestrator',
`Executing processor: ${processor.id} (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 ${processor.id}:`,
error,
);
return;
}
}
this.env.inbox.drainConsumed(inboxSnapshot.getConsumedIds());
}
}
@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import { describe, it, expect } from 'vitest';
import { createBlobDegradationProcessor } from './blobDegradationProcessor.js';
import {
createMockProcessArgs,
createMockEnvironment,
createDummyNode,
} from '../testing/contextTestUtils.js';
import type { UserPrompt, SemanticPart, ConcreteNode } from '../graph/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 = createBlobDegradationProcessor(
'BlobDegradationProcessor',
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 = createBlobDegradationProcessor(
'BlobDegradationProcessor',
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 = createBlobDegradationProcessor(
'BlobDegradationProcessor',
env,
);
const targets: ConcreteNode[] = [];
const result = await processor.process(createMockProcessArgs(targets));
expect(result).toBe(targets);
});
});
@@ -0,0 +1,153 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import type { JSONSchemaType } from 'ajv';
import type { ProcessArgs, ContextProcessor } from '../pipeline.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { ConcreteNode, UserPrompt } from '../graph/types.js';
import type { ContextEnvironment } from '../pipeline/environment.js';
import { sanitizeFilenamePart } from '../../utils/fileUtils.js';
export type BlobDegradationProcessorOptions = Record<string, never>;
export const BlobDegradationProcessorOptionsSchema: JSONSchemaType<BlobDegradationProcessorOptions> =
{
type: 'object',
properties: {},
required: [],
};
export function createBlobDegradationProcessor(
id: string,
env: ContextEnvironment,
): ContextProcessor {
return {
id,
name: 'BlobDegradationProcessor',
process: async ({ targets }: ProcessArgs) => {
if (targets.length === 0) {
return targets;
}
let directoryCreated = false;
let blobOutputsDir = path.join(env.projectTempDir, 'degraded-blobs');
const sessionId = env.sessionId;
if (sessionId) {
blobOutputsDir = path.join(
blobOutputsDir,
`session-${sanitizeFilenamePart(sessionId)}`,
);
}
const ensureDir = async () => {
if (!directoryCreated) {
await fs.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()}_${randomUUID()}.${ext}`;
const filePath = path.join(blobOutputsDir, fileName);
const buffer = Buffer.from(part.data, 'base64');
await fs.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 = env.tokenCalculator.estimateTokensForParts([
{
inlineData: { mimeType: part.mimeType, data: part.data },
},
]);
const newTokens = 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 = env.tokenCalculator.estimateTokensForParts([
{
fileData: {
mimeType: part.mimeType,
fileUri: part.fileUri,
},
},
]);
const newTokens = env.tokenCalculator.estimateTokensForParts([
{ text: newText },
]);
tokensSaved = oldTokens - newTokens;
break;
}
case 'raw_part': {
newText = `[Unknown Part degraded to text to preserve context window.]`;
const oldTokens = env.tokenCalculator.estimateTokensForParts([
part.part,
]);
const newTokens = 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: randomUUID(),
semanticParts: newParts,
replacesId: node.id,
};
returnedNodes.push(degradedNode);
} else {
returnedNodes.push(node);
}
break;
}
default:
returnedNodes.push(node);
break;
}
}
return returnedNodes;
},
};
}
@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
ContextProcessor,
BackstopTargetOptions,
ProcessArgs,
} from '../pipeline.js';
import type { ConcreteNode } from '../graph/types.js';
import type { JSONSchemaType } from 'ajv';
import type { ContextEnvironment } from '../pipeline/environment.js';
export type HistoryTruncationProcessorOptions = BackstopTargetOptions;
export const HistoryTruncationProcessorOptionsSchema: JSONSchemaType<HistoryTruncationProcessorOptions> =
{
type: 'object',
properties: {
target: {
type: 'string',
enum: ['incremental', 'freeNTokens', 'max'],
nullable: true,
},
freeTokensTarget: { type: 'number', nullable: true },
},
required: [],
};
export function createHistoryTruncationProcessor(
id: string,
env: ContextEnvironment,
options: HistoryTruncationProcessorOptions,
): ContextProcessor {
return {
id,
name: 'HistoryTruncationProcessor',
process: async ({ targets }: ProcessArgs) => {
const strategy = options.target ?? 'max';
const keptNodes: ConcreteNode[] = [];
if (strategy === 'incremental') {
// 'incremental' simply drops the single oldest node in the targets, ignoring tokens.
let removedNodes = 0;
for (const node of targets) {
if (removedNodes < 1) {
removedNodes++;
continue;
}
keptNodes.push(node);
}
return keptNodes;
}
let targetTokensToRemove = 0;
if (strategy === 'freeNTokens') {
targetTokensToRemove = 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;
// 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 += env.tokenCalculator.getTokenCost(node);
}
return keptNodes;
},
};
}
@@ -0,0 +1,152 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import { describe, it, expect } from 'vitest';
import { createNodeDistillationProcessor } from './nodeDistillationProcessor.js';
import {
createMockProcessArgs,
createMockEnvironment,
createDummyNode,
createDummyToolNode,
createMockLlmClient,
} from '../testing/contextTestUtils.js';
import type {
UserPrompt,
AgentThought,
ToolExecution,
} from '../graph/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 = createNodeDistillationProcessor(
'NodeDistillationProcessor',
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 = createNodeDistillationProcessor(
'NodeDistillationProcessor',
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,203 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import type { JSONSchemaType } from 'ajv';
import type { ContextProcessor, ProcessArgs } from '../pipeline.js';
import type { ConcreteNode } from '../graph/types.js';
import type { ContextEnvironment } from '../pipeline/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 const NodeDistillationProcessorOptionsSchema: JSONSchemaType<NodeDistillationProcessorOptions> =
{
type: 'object',
properties: {
nodeThresholdTokens: { type: 'number' },
},
required: ['nodeThresholdTokens'],
};
export function createNodeDistillationProcessor(
id: string,
env: ContextEnvironment,
options: NodeDistillationProcessorOptions,
): ContextProcessor {
const generateSummary = async (
text: string,
contextInfo: string,
): Promise<string> => {
try {
const response = await env.llmClient.generateContent({
role: LlmRole.UTILITY_COMPRESSOR,
modelConfigKey: { model: 'gemini-3-flash-base' },
promptId: 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
}
};
return {
id,
name: 'NodeDistillationProcessor',
process: async ({ targets }: ProcessArgs) => {
const semanticConfig = options;
const limitTokens = semanticConfig.nodeThresholdTokens;
const thresholdChars = 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 generateSummary(part.text, 'User Prompt');
const newTokens = env.tokenCalculator.estimateTokensForParts([
{ text: summary },
]);
const oldTokens = env.tokenCalculator.estimateTokensForParts([
{ text: part.text },
]);
if (newTokens < oldTokens) {
newParts[j] = { type: 'text', text: summary };
modified = true;
}
}
}
if (modified) {
returnedNodes.push({
...node,
id: randomUUID(),
semanticParts: newParts,
replacesId: node.id,
});
} else {
returnedNodes.push(node);
}
break;
}
case 'AGENT_THOUGHT': {
if (node.text.length > thresholdChars) {
const summary = await generateSummary(node.text, 'Agent Thought');
const newTokens = env.tokenCalculator.estimateTokensForParts([
{ text: summary },
]);
const oldTokens = env.tokenCalculator.getTokenCost(node);
if (newTokens < oldTokens) {
returnedNodes.push({
...node,
id: randomUUID(),
text: summary,
replacesId: node.id,
});
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 generateSummary(
stringifiedObs,
node.toolName || 'unknown',
);
const newObsObject = { summary };
const newObsTokens = env.tokenCalculator.estimateTokensForParts([
{
functionResponse: {
name: node.toolName || 'unknown',
response: newObsObject,
id: node.id,
},
},
]);
const oldObsTokens =
node.tokens?.observation ??
env.tokenCalculator.getTokenCost(node);
const intentTokens = node.tokens?.intent ?? 0;
if (newObsTokens < oldObsTokens) {
returnedNodes.push({
...node,
id: randomUUID(),
observation: newObsObject,
tokens: {
intent: intentTokens,
observation: newObsTokens,
},
replacesId: node.id,
});
break;
}
}
returnedNodes.push(node);
break;
}
default:
returnedNodes.push(node);
break;
}
}
return returnedNodes;
},
};
}
@@ -0,0 +1,136 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import { describe, it, expect } from 'vitest';
import { createNodeTruncationProcessor } from './nodeTruncationProcessor.js';
import {
createMockProcessArgs,
createMockEnvironment,
createDummyNode,
} from '../testing/contextTestUtils.js';
import type { UserPrompt, AgentThought, AgentYield } from '../graph/types.js';
describe('NodeTruncationProcessor', () => {
it('should truncate nodes that exceed maxTokensPerNode', async () => {
// env.tokenCalculator uses charsPerToken=1 natively.
const env = createMockEnvironment();
const processor = createNodeTruncationProcessor(
'NodeTruncationProcessor',
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 = createNodeTruncationProcessor(
'NodeTruncationProcessor',
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,144 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import type { JSONSchemaType } from 'ajv';
import type { ContextProcessor, ProcessArgs } from '../pipeline.js';
import type { ContextEnvironment } from '../pipeline/environment.js';
import { truncateProportionally } from '../truncation.js';
import type { ConcreteNode } from '../graph/types.js';
export interface NodeTruncationProcessorOptions {
maxTokensPerNode: number;
}
export const NodeTruncationProcessorOptionsSchema: JSONSchemaType<NodeTruncationProcessorOptions> =
{
type: 'object',
properties: {
maxTokensPerNode: { type: 'number' },
},
required: ['maxTokensPerNode'],
};
export function createNodeTruncationProcessor(
id: string,
env: ContextEnvironment,
options: NodeTruncationProcessorOptions,
): ContextProcessor {
const 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 = env.tokenCalculator.estimateTokensForString(newText);
const oldTokens = env.tokenCalculator.estimateTokensForString(text);
const tokensSaved = oldTokens - newTokens;
if (tokensSaved > 0) {
return { text: newText, newTokens, oldTokens, tokensSaved };
}
}
return null;
};
return {
id,
name: 'NodeTruncationProcessor',
process: async ({ targets }: ProcessArgs) => {
if (targets.length === 0) {
return targets;
}
const { maxTokensPerNode } = options;
const limitChars = 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 = tryApplySquash(part.text, limitChars);
if (squashResult) {
newParts[j] = { type: 'text', text: squashResult.text };
modified = true;
}
}
}
if (modified) {
returnedNodes.push({
...node,
id: randomUUID(),
semanticParts: newParts,
replacesId: node.id,
});
} else {
returnedNodes.push(node);
}
break;
}
case 'AGENT_THOUGHT': {
const squashResult = tryApplySquash(node.text, limitChars);
if (squashResult) {
returnedNodes.push({
...node,
id: randomUUID(),
text: squashResult.text,
replacesId: node.id,
});
} else {
returnedNodes.push(node);
}
break;
}
case 'AGENT_YIELD': {
const squashResult = tryApplySquash(node.text, limitChars);
if (squashResult) {
returnedNodes.push({
...node,
id: randomUUID(),
text: squashResult.text,
replacesId: node.id,
});
} else {
returnedNodes.push(node);
}
break;
}
default:
returnedNodes.push(node);
break;
}
}
return returnedNodes;
},
};
}
@@ -0,0 +1,98 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { createRollingSummaryProcessor } 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 = createRollingSummaryProcessor(
'RollingSummaryProcessor',
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 = createRollingSummaryProcessor(
'RollingSummaryProcessor',
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 = createRollingSummaryProcessor(
'RollingSummaryProcessor',
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,157 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import type { JSONSchemaType } from 'ajv';
import type {
ContextProcessor,
ProcessArgs,
BackstopTargetOptions,
} from '../pipeline.js';
import type { ContextEnvironment } from '../pipeline/environment.js';
import type { ConcreteNode, RollingSummary } from '../graph/types.js';
import { debugLogger } from '../../utils/debugLogger.js';
import { LlmRole } from '../../telemetry/llmRole.js';
export interface RollingSummaryProcessorOptions extends BackstopTargetOptions {
systemInstruction?: string;
}
export const RollingSummaryProcessorOptionsSchema: JSONSchemaType<RollingSummaryProcessorOptions> =
{
type: 'object',
properties: {
target: {
type: 'string',
enum: ['incremental', 'freeNTokens', 'max'],
nullable: true,
},
freeTokensTarget: { type: 'number', nullable: true },
maxRollingSummaries: { type: 'number', nullable: true },
systemInstruction: { type: 'string', nullable: true },
},
required: [],
};
export function createRollingSummaryProcessor(
id: string,
env: ContextEnvironment,
options: RollingSummaryProcessorOptions,
): ContextProcessor {
const generateRollingSummary = async (
nodes: ConcreteNode[],
): Promise<string> => {
let transcript = '';
for (const node of nodes) {
let nodeContent = '';
if ('text' in node && typeof node.text === 'string') {
nodeContent = node.text;
} else if ('semanticParts' in node) {
nodeContent = JSON.stringify(node.semanticParts);
} else if ('observation' in node) {
nodeContent =
typeof node.observation === 'string'
? node.observation
: JSON.stringify(node.observation);
}
transcript += `[${node.type}]: ${nodeContent}\n`;
}
const systemPrompt =
options.systemInstruction ??
`You are an expert context compressor. Your job is to drastically shorten the provided conversational transcript while preserving the absolute core semantic meaning, facts, and intent. Omit all conversational filler, pleasantries, or redundant information. Return ONLY the compressed summary.`;
const response = await env.llmClient.generateContent({
role: LlmRole.UTILITY_COMPRESSOR,
modelConfigKey: { model: 'gemini-3-flash-base' },
promptId: env.promptId,
abortSignal: new AbortController().signal,
contents: [{ role: 'user', parts: [{ text: transcript }] }],
systemInstruction: { role: 'system', parts: [{ text: systemPrompt }] },
});
const candidate = response.candidates?.[0];
const textPart = candidate?.content?.parts?.[0];
return textPart?.text || '';
};
return {
id,
name: 'RollingSummaryProcessor',
process: async ({ targets }: ProcessArgs) => {
if (targets.length === 0) return targets;
const strategy = options.target ?? 'max';
const nodesToSummarize: ConcreteNode[] = [];
if (strategy === 'incremental') {
// 'incremental' simply summarizes the minimum viable chunk (the oldest 2 nodes), ignoring token math.
for (const node of targets) {
if (node.id === targets[0].id && node.type === 'USER_PROMPT') {
continue; // Keep system prompt
}
nodesToSummarize.push(node);
if (nodesToSummarize.length >= 2) break; // We have enough for a minimum rolling summary
}
} else {
let targetTokensToRemove = 0;
if (strategy === 'freeNTokens') {
targetTokensToRemove = options.freeTokensTarget ?? Infinity;
} else if (strategy === 'max') {
targetTokensToRemove = Infinity;
}
if (targetTokensToRemove > 0) {
let deficitAccumulator = 0;
for (const node of targets) {
if (node.id === targets[0].id && node.type === 'USER_PROMPT') {
continue; // Keep system prompt
}
nodesToSummarize.push(node);
deficitAccumulator += 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 generateRollingSummary(nodesToSummarize);
const newId = randomUUID();
const summaryNode: RollingSummary = {
id: newId,
logicalParentId: newId,
type: 'ROLLING_SUMMARY',
timestamp: Date.now(),
text: snapshotText,
abstractsIds: nodesToSummarize.map((n) => n.id),
};
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,125 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { createStateSnapshotAsyncProcessor } from './stateSnapshotAsyncProcessor.js';
import {
createMockEnvironment,
createDummyNode,
createMockProcessArgs,
} from '../testing/contextTestUtils.js';
import type { InboxMessage } from '../pipeline.js';
import type { InboxSnapshotImpl } from '../pipeline/inbox.js';
describe('StateSnapshotAsyncProcessor', () => {
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 = createStateSnapshotAsyncProcessor(
'StateSnapshotAsyncProcessor',
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];
await worker.process(createMockProcessArgs(targets, targets, []));
// 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',
}),
);
});
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 = createStateSnapshotAsyncProcessor(
'StateSnapshotAsyncProcessor',
env,
{ type: 'accumulate' },
);
const nodeC = createDummyNode('ep2', 'USER_PROMPT', 50, {}, 'node-C');
const targets = [nodeC];
const inboxMessages: InboxMessage[] = [
{
id: 'draft-1',
topic: 'PROPOSED_SNAPSHOT',
timestamp: Date.now() - 1000,
payload: {
consumedIds: ['node-A', 'node-B'],
newText: '<old snapshot>',
type: 'accumulate',
},
},
];
const args = createMockProcessArgs(targets, targets, inboxMessages);
await worker.process(args);
// The old draft should be consumed
expect(
(args.inbox as InboxSnapshotImpl).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',
}),
);
// 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 = createStateSnapshotAsyncProcessor(
'StateSnapshotAsyncProcessor',
env,
{ type: 'accumulate' },
);
await worker.process(createMockProcessArgs([], [], []));
expect(env.llmClient.generateContent).not.toHaveBeenCalled();
expect(publishSpy).not.toHaveBeenCalled();
});
});

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