feat: add Forever Mode with time-based scheduled work and A2A listener

Add --forever CLI flag that enables autonomous agent operation with
scheduled work, context management, A2A protocol support, and session
optimization.

Core features:
- Time-based WorkScheduler: manages a sorted list of scheduled prompts
  that fire at absolute or relative times, persisted across sessions
- schedule_work tool: add/cancel scheduled prompts with 'at' (local
  time) or 'inMinutes' (relative) params; current time and schedule
  prepended to every model turn
- A2A HTTP listener: JSON-RPC 2.0 server bridges external messages
  into the session (message/send, tasks/get, responses/poll)
- PreCompress hook: hooks can return newHistory to replace built-in
  LLM compression
- Idle hook: fires after configurable inactivity, can auto-submit
  prompts
- Forever mode disables MemoryTool, EnterPlanModeTool, interactive
  shell

UI:
- ScheduledWorkDisplay component shows all pending items above the
  context summary bar
- A2A port shown in StatusDisplay when active

Session optimization for long-running sessions:
- Record lastCompressionIndex on ConversationRecord; on resume, only
  load post-compression messages
- Restore scheduled work items on session resume (past-due fire
  immediately)
- Skip file I/O in updateMessagesFromHistory when no tool results to
  sync
- Prune UI history to last 50 items after each context compression
This commit is contained in:
Sandy Tao
2026-03-17 21:34:50 -07:00
parent a5a461c234
commit 1a6f730439
51 changed files with 2737 additions and 55 deletions
+4 -1
View File
@@ -360,7 +360,10 @@ export class GeminiAgent {
config.setFileSystemService(acpFileSystemService);
}
const clientHistory = convertSessionToClientHistory(sessionData.messages);
const clientHistory = convertSessionToClientHistory(
sessionData.messages,
sessionData.lastCompressionIndex,
);
const geminiClient = config.getGeminiClient();
await geminiClient.initialize();
+8
View File
@@ -97,6 +97,7 @@ export interface CliArgs {
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
isCommand: boolean | undefined;
forever: boolean | undefined;
}
/**
@@ -298,6 +299,12 @@ export async function parseArguments(
.option('accept-raw-output-risk', {
type: 'boolean',
description: 'Suppress the security warning when using --raw-output.',
})
.option('forever', {
type: 'boolean',
description:
'Run as a long-running autonomous agent with auto-resume and schedule_work support.',
default: false,
}),
)
// Register MCP subcommands
@@ -893,6 +900,7 @@ export async function loadCliConfig(
};
},
enableConseca: settings.security?.enableConseca,
isForeverMode: !!argv.forever,
});
}
+12
View File
@@ -2312,6 +2312,18 @@ const SETTINGS_SCHEMA = {
ref: 'HookDefinitionArray',
mergeStrategy: MergeStrategy.CONCAT,
},
Idle: {
type: 'array',
label: 'Idle Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute after a period of inactivity. Can trigger maintenance tasks like memory consolidation.',
showInDialog: false,
ref: 'HookDefinitionArray',
mergeStrategy: MergeStrategy.CONCAT,
},
},
additionalProperties: {
type: 'array',
+453
View File
@@ -0,0 +1,453 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import http from 'node:http';
import { writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { appEvents, AppEvent } from './utils/events.js';
// --- A2A Task management ---
interface A2AResponseMessage {
kind: 'message';
role: 'agent';
parts: Array<{ kind: 'text'; text: string }>;
messageId: string;
}
interface A2ATask {
id: string;
contextId: string;
status: {
state: 'submitted' | 'working' | 'completed' | 'failed';
timestamp: string;
message?: A2AResponseMessage;
};
}
const tasks = new Map<string, A2ATask>();
const TASK_CLEANUP_DELAY_MS = 10 * 60 * 1000; // 10 minutes
const DEFAULT_BLOCKING_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
interface ResponseWaiter {
taskId: string;
resolve: (text: string) => void;
}
const responseWaiters: ResponseWaiter[] = [];
// Queue for unsolicited responses (e.g. forever mode auto-resume output)
const unsolicitedResponses: string[] = [];
/**
* Called by AppContainer when streaming transitions from non-Idle to Idle.
* If there's a pending A2A task, resolves it. Otherwise queues as unsolicited.
*/
export function notifyResponse(responseText: string): void {
if (!responseText) return;
const waiter = responseWaiters.shift();
if (!waiter) {
// No A2A task waiting — queue as unsolicited (forever mode, etc.)
unsolicitedResponses.push(responseText);
return;
}
const task = tasks.get(waiter.taskId);
if (task) {
task.status = {
state: 'completed',
timestamp: new Date().toISOString(),
message: {
kind: 'message',
role: 'agent',
parts: [{ kind: 'text', text: responseText }],
messageId: crypto.randomUUID(),
},
};
scheduleTaskCleanup(task.id);
}
waiter.resolve(responseText);
}
/**
* Drain all unsolicited responses (from forever mode auto-resume, etc.).
*/
export function drainUnsolicitedResponses(): string[] {
return unsolicitedResponses.splice(0, unsolicitedResponses.length);
}
/**
* Returns true if there are any in-flight tasks waiting for a response.
*/
export function hasPendingTasks(): boolean {
return responseWaiters.length > 0;
}
/**
* Called when streaming starts (Idle -> non-Idle) to mark the oldest
* submitted task as "working".
*/
export function markTasksWorking(): void {
const waiter = responseWaiters[0];
if (!waiter) return;
const task = tasks.get(waiter.taskId);
if (task && task.status.state === 'submitted') {
task.status = {
state: 'working',
timestamp: new Date().toISOString(),
};
}
}
function scheduleTaskCleanup(taskId: string): void {
setTimeout(() => {
tasks.delete(taskId);
}, TASK_CLEANUP_DELAY_MS);
}
function createTask(): A2ATask {
const task: A2ATask = {
id: crypto.randomUUID(),
contextId: `session-${process.pid}`,
status: {
state: 'submitted',
timestamp: new Date().toISOString(),
},
};
tasks.set(task.id, task);
return task;
}
function formatTaskResult(task: A2ATask): object {
return {
kind: 'task',
id: task.id,
contextId: task.contextId,
status: task.status,
};
}
// --- JSON-RPC helpers ---
interface JsonRpcRequest {
jsonrpc?: string;
id?: string | number | null;
method?: string;
params?: Record<string, unknown>;
}
function jsonRpcSuccess(id: string | number | null, result: object): object {
return { jsonrpc: '2.0', id, result };
}
function jsonRpcError(
id: string | number | null,
code: number,
message: string,
): object {
return { jsonrpc: '2.0', id, error: { code, message } };
}
// --- HTTP utilities ---
function getSessionsDir(): string {
return join(os.homedir(), '.gemini', 'sessions');
}
function getPortFilePath(): string {
return join(getSessionsDir(), `interactive-${process.pid}.port`);
}
function buildAgentCard(port: number): object {
return {
name: 'Gemini CLI Interactive Session',
url: `http://localhost:${port}/`,
protocolVersion: '0.3.0',
provider: { organization: 'Google', url: 'https://google.com' },
capabilities: { streaming: false, pushNotifications: false },
defaultInputModes: ['text'],
defaultOutputModes: ['text'],
skills: [
{
id: 'interactive_session',
name: 'Interactive Session',
description: 'Send messages to the live interactive Gemini CLI session',
},
],
};
}
interface A2AMessagePart {
kind?: string;
text?: string;
}
function extractTextFromParts(
parts: A2AMessagePart[] | undefined,
): string | null {
if (!Array.isArray(parts)) {
return null;
}
const texts: string[] = [];
for (const part of parts) {
if (part.kind === 'text' && typeof part.text === 'string') {
texts.push(part.text);
}
}
return texts.length > 0 ? texts.join('\n') : null;
}
function sendJson(
res: http.ServerResponse,
statusCode: number,
data: object,
): void {
const body = JSON.stringify(data);
res.writeHead(statusCode, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
});
res.end(body);
}
function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let size = 0;
const maxSize = 1024 * 1024; // 1MB limit
req.on('data', (chunk: Buffer) => {
size += chunk.length;
if (size > maxSize) {
req.destroy();
reject(new Error('Request body too large'));
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
req.on('error', reject);
});
}
// --- JSON-RPC request handlers ---
function handleMessageSend(
rpcId: string | number | null,
params: Record<string, unknown>,
res: http.ServerResponse,
): void {
const messageVal = params['message'];
const message =
messageVal && typeof messageVal === 'object'
? (messageVal as { role?: string; parts?: A2AMessagePart[] })
: undefined;
const text = extractTextFromParts(message?.parts);
if (!text) {
sendJson(
res,
200,
jsonRpcError(
rpcId,
-32602,
'Missing or empty text. Expected: params.message.parts with kind "text".',
),
);
return;
}
const task = createTask();
// Inject message into the session
appEvents.emit(AppEvent.ExternalMessage, text);
// Block until response (standard A2A message/send semantics)
const timer = setTimeout(() => {
const idx = responseWaiters.findIndex((w) => w.taskId === task.id);
if (idx !== -1) {
responseWaiters.splice(idx, 1);
}
task.status = {
state: 'failed',
timestamp: new Date().toISOString(),
};
scheduleTaskCleanup(task.id);
sendJson(res, 200, jsonRpcError(rpcId, -32000, 'Request timed out'));
}, DEFAULT_BLOCKING_TIMEOUT_MS);
responseWaiters.push({
taskId: task.id,
resolve: () => {
clearTimeout(timer);
// Task is already updated in notifyResponse
const updatedTask = tasks.get(task.id);
sendJson(
res,
200,
jsonRpcSuccess(rpcId, formatTaskResult(updatedTask ?? task)),
);
},
});
}
function handleResponsesPoll(
rpcId: string | number | null,
res: http.ServerResponse,
): void {
const responses = drainUnsolicitedResponses();
sendJson(res, 200, jsonRpcSuccess(rpcId, { responses }));
}
function handleTasksGet(
rpcId: string | number | null,
params: Record<string, unknown>,
res: http.ServerResponse,
): void {
const taskId = params['id'];
if (typeof taskId !== 'string') {
sendJson(
res,
200,
jsonRpcError(rpcId, -32602, 'Missing or invalid params.id'),
);
return;
}
const task = tasks.get(taskId);
if (!task) {
sendJson(res, 200, jsonRpcError(rpcId, -32001, 'Task not found'));
return;
}
sendJson(res, 200, jsonRpcSuccess(rpcId, formatTaskResult(task)));
}
// --- Server ---
export interface ExternalListenerResult {
port: number;
cleanup: () => void;
}
/**
* Start an embedded HTTP server that accepts A2A-format JSON-RPC messages
* and bridges them into the interactive session's message queue.
*/
export function startExternalListener(options?: {
port?: number;
}): Promise<ExternalListenerResult> {
const port = options?.port ?? 0;
return new Promise((resolve, reject) => {
const server = http.createServer(
(req: http.IncomingMessage, res: http.ServerResponse) => {
const url = new URL(req.url ?? '/', `http://localhost`);
// GET /.well-known/agent-card.json
if (
req.method === 'GET' &&
url.pathname === '/.well-known/agent-card.json'
) {
const address = server.address();
const actualPort =
typeof address === 'object' && address ? address.port : port;
sendJson(res, 200, buildAgentCard(actualPort));
return;
}
// POST / — JSON-RPC 2.0 routing
if (req.method === 'POST' && url.pathname === '/') {
readBody(req)
.then((rawBody) => {
let parsed: JsonRpcRequest;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
parsed = JSON.parse(rawBody) as JsonRpcRequest;
} catch {
sendJson(
res,
200,
jsonRpcError(null, -32700, 'Parse error: invalid JSON'),
);
return;
}
const rpcId = parsed.id ?? null;
const method = parsed.method;
const params = parsed.params ?? {};
switch (method) {
case 'message/send':
handleMessageSend(rpcId, params, res);
break;
case 'tasks/get':
handleTasksGet(rpcId, params, res);
break;
case 'responses/poll':
handleResponsesPoll(rpcId, res);
break;
default:
sendJson(
res,
200,
jsonRpcError(
rpcId,
-32601,
`Method not found: ${method ?? '(none)'}`,
),
);
}
})
.catch(() => {
sendJson(
res,
200,
jsonRpcError(null, -32603, 'Failed to read request body'),
);
});
return;
}
// 404 for everything else
sendJson(res, 404, { error: 'Not found' });
},
);
server.listen(port, '127.0.0.1', () => {
const address = server.address();
const actualPort =
typeof address === 'object' && address ? address.port : port;
// Write port file
try {
const sessionsDir = getSessionsDir();
mkdirSync(sessionsDir, { recursive: true });
writeFileSync(getPortFilePath(), String(actualPort), 'utf-8');
} catch {
// Non-fatal: port file is a convenience, not a requirement
}
const cleanup = () => {
server.close();
try {
unlinkSync(getPortFilePath());
} catch {
// Ignore: file may already be deleted
}
};
resolve({ port: actualPort, cleanup });
});
server.on('error', (err) => {
reject(err);
});
});
}
+1
View File
@@ -513,6 +513,7 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
forever: undefined,
});
await act(async () => {
+20
View File
@@ -10,6 +10,8 @@ import { basename } from 'node:path';
import { AppContainer } from './ui/AppContainer.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { registerCleanup, setupTtyCheck } from './utils/cleanup.js';
import { startExternalListener } from './external-listener.js';
import { appEvents, AppEvent } from './utils/events.js';
import {
type StartupWarning,
type Config,
@@ -181,6 +183,24 @@ export async function startInteractiveUI(
registerCleanup(() => instance.unmount());
// Auto-start A2A HTTP listener in Forever Mode
if (config.getIsForeverMode()) {
try {
const listener = await startExternalListener({ port: 0 });
registerCleanup(listener.cleanup);
appEvents.emit(AppEvent.A2AListenerStarted, listener.port);
coreEvents.emitFeedback(
'info',
`A2A endpoint listening on port ${listener.port}`,
);
} catch (err) {
coreEvents.emitFeedback(
'warning',
`Failed to start A2A listener: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
registerCleanup(setupTtyCheck());
}
+1
View File
@@ -222,6 +222,7 @@ export async function runNonInteractive({
await geminiClient.resumeChat(
convertSessionToClientHistory(
resumedSessionData.conversation.messages,
resumedSessionData.conversation.lastCompressionIndex,
),
resumedSessionData,
);
@@ -173,6 +173,14 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
validatePathAccess: vi.fn().mockReturnValue(null),
getUseAlternateBuffer: vi.fn().mockReturnValue(false),
getIsForeverMode: vi.fn().mockReturnValue(false),
getWorkScheduler: vi.fn().mockReturnValue({
on: vi.fn(),
off: vi.fn(),
serialize: vi.fn().mockReturnValue([]),
restore: vi.fn(),
dispose: vi.fn(),
}),
...overrides,
}) as unknown as Config;
+1
View File
@@ -82,6 +82,7 @@ describe('App', () => {
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
pruneItems: vi.fn(),
},
history: [],
pendingHistoryItems: [],
+87
View File
@@ -127,6 +127,7 @@ import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js';
import { notifyResponse, markTasksWorking } from '../external-listener.js';
import { type UpdateObject } from './utils/updateCheck.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
@@ -1142,6 +1143,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
terminalHeight,
embeddedShellFocused,
consumePendingHints,
historyManager.pruneItems,
);
toggleBackgroundShellRef.current = toggleBackgroundShell;
@@ -1213,6 +1215,89 @@ Logging in with Google... Restarting Gemini CLI to continue.
isMcpReady,
});
// --- A2A listener integration ---
const [a2aListenerPort, setA2aListenerPort] = useState<number | null>(null);
useEffect(() => {
const handler = (port: number) => {
setA2aListenerPort(port);
};
appEvents.on(AppEvent.A2AListenerStarted, handler);
return () => {
appEvents.off(AppEvent.A2AListenerStarted, handler);
};
}, []);
// Bridge external messages from A2A HTTP listener to message queue
useEffect(() => {
const handler = (text: string) => {
addMessage(text);
};
appEvents.on(AppEvent.ExternalMessage, handler);
return () => {
appEvents.off(AppEvent.ExternalMessage, handler);
};
}, [addMessage]);
// Wire WorkScheduler: inject fired prompts and persist schedule changes
useEffect(() => {
const scheduler = config.getWorkScheduler();
const onFire = (prompt: string) => {
appEvents.emit(AppEvent.ExternalMessage, prompt);
};
const onChanged = () => {
// Persist pending items to the session file
const recordingService = config
.getGeminiClient()
?.getChatRecordingService();
if (recordingService) {
recordingService.recordScheduledWork(scheduler.serialize());
}
};
scheduler.on('fire', onFire);
scheduler.on('changed', onChanged);
return () => {
scheduler.off('fire', onFire);
scheduler.off('changed', onChanged);
};
}, [config]);
// Track streaming state transitions for A2A response capture
const prevStreamingStateRef = useRef(streamingState);
useEffect(() => {
const prev = prevStreamingStateRef.current;
prevStreamingStateRef.current = streamingState;
// Mark tasks as "working" when streaming starts
if (
prev === StreamingState.Idle &&
streamingState !== StreamingState.Idle
) {
markTasksWorking();
}
// Capture response when streaming ends (for A2A tasks or unsolicited output)
if (
prev !== StreamingState.Idle &&
streamingState === StreamingState.Idle
) {
const history = historyManager.history;
const parts: string[] = [];
for (let i = history.length - 1; i >= 0; i--) {
const item = history[i];
if (item.type !== 'gemini' && item.type !== 'gemini_content') break;
if (typeof item.text === 'string' && item.text) {
parts.unshift(item.text);
}
}
notifyResponse(parts.join('\n'));
}
}, [streamingState, historyManager.history]);
cancelHandlerRef.current = useCallback(
(shouldRestorePrompt: boolean = true) => {
const pendingHistoryItems = [
@@ -2300,6 +2385,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
adminSettingsChanged,
newAgents,
showIsExpandableHint,
a2aListenerPort,
hintMode:
config.isModelSteeringEnabled() &&
isToolExecuting([
@@ -2428,6 +2514,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
adminSettingsChanged,
newAgents,
showIsExpandableHint,
a2aListenerPort,
],
);
@@ -61,6 +61,10 @@ vi.mock('./StatusDisplay.js', () => ({
StatusDisplay: () => <Text>StatusDisplay</Text>,
}));
vi.mock('./ScheduledWorkDisplay.js', () => ({
ScheduledWorkDisplay: () => null,
}));
vi.mock('./ToastDisplay.js', () => ({
ToastDisplay: () => <Text>ToastDisplay</Text>,
shouldShowToast: (uiState: UIState) =>
@@ -202,6 +206,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
activeHooks: [],
isBackgroundShellVisible: false,
embeddedShellFocused: false,
a2aListenerPort: null,
quota: {
userTier: undefined,
stats: undefined,
@@ -13,6 +13,7 @@ import {
} from '@google/gemini-cli-core';
import { LoadingIndicator } from './LoadingIndicator.js';
import { StatusDisplay } from './StatusDisplay.js';
import { ScheduledWorkDisplay } from './ScheduledWorkDisplay.js';
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
@@ -336,6 +337,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
)}
{showShortcutsHelp && <ShortcutsHelp />}
{showUiDetails && <HorizontalLine />}
<ScheduledWorkDisplay />
{showUiDetails && (
<Box
justifyContent={
@@ -0,0 +1,66 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useConfig } from '../contexts/ConfigContext.js';
import type { ScheduledItem } from '@google/gemini-cli-core';
/**
* Displays all pending scheduled work items above the context summary.
* Only renders when there are pending items.
*/
export const ScheduledWorkDisplay: React.FC = () => {
const config = useConfig();
const [items, setItems] = useState<readonly ScheduledItem[]>([]);
useEffect(() => {
const scheduler = config.getWorkScheduler();
const update = () => {
setItems(scheduler.getPendingItems());
};
scheduler.on('changed', update);
update();
return () => {
scheduler.off('changed', update);
};
}, [config]);
if (items.length === 0) {
return null;
}
return (
<Box flexDirection="column" paddingX={1}>
<Text color={theme.text.secondary}>
Scheduled work ({items.length}):
</Text>
{items.map((item) => {
const timeStr = item.fireAt.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
const diffMs = item.fireAt.getTime() - Date.now();
const diffMins = Math.max(0, Math.ceil(diffMs / 60000));
const truncatedPrompt =
item.prompt.length > 60
? item.prompt.slice(0, 57) + '...'
: item.prompt;
return (
<Text key={item.id} color={theme.text.secondary}>
{' '}
{timeStr} (in {diffMins}m) {truncatedPrompt}
</Text>
);
})}
</Box>
);
};
@@ -52,6 +52,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
geminiMdFileCount: 0,
contextFileNames: [],
backgroundShellCount: 0,
a2aListenerPort: null,
buffer: { text: '' },
history: [{ id: 1, type: 'user', text: 'test' }],
...overrides,
@@ -171,4 +172,16 @@ describe('StatusDisplay', () => {
expect(lastFrame()).toContain('Shells: 3');
unmount();
});
it('renders A2A listener port when a2aListenerPort is set', async () => {
const uiState = createMockUIState({
a2aListenerPort: 8080,
});
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toContain('A2A :8080');
unmount();
});
});
@@ -28,6 +28,12 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
return <Text color={theme.status.error}>|_|</Text>;
}
if (uiState.a2aListenerPort !== null) {
return (
<Text color={theme.text.accent}> A2A :{uiState.a2aListenerPort}</Text>
);
}
if (
uiState.activeHooks.length > 0 &&
settings.merged.hooksConfig.notifications
@@ -223,6 +223,7 @@ export interface UIState {
showIsExpandableHint: boolean;
hintMode: boolean;
hintBuffer: string;
a2aListenerPort: number | null;
transientMessage: {
text: string;
type: TransientMessageType;
@@ -57,6 +57,7 @@ describe('handleCreditsFlow', () => {
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
pruneItems: vi.fn(),
};
isDialogPending = { current: false };
mockSetOverageMenuRequest = vi.fn();
+79 -2
View File
@@ -220,6 +220,7 @@ export const useGeminiStream = (
terminalHeight: number,
isShellFocused?: boolean,
consumeUserHint?: () => string | null,
pruneItems?: () => void,
) => {
const [initError, setInitError] = useState<string | null>(null);
const [retryStatus, setRetryStatus] = useState<RetryAttemptPayload | null>(
@@ -256,6 +257,7 @@ export const useGeminiStream = (
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
useStateAndRef<boolean>(true);
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
const { startNewPrompt, getPromptCount } = useSessionStats();
const storage = config.storage;
const logger = useLogger(storage);
@@ -1163,8 +1165,12 @@ export const useGeminiStream = (
} as HistoryItemInfo,
userMessageTimestamp,
);
// Prune old UI history items to prevent unbounded memory growth
// in long-running sessions.
pruneItems?.();
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config],
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config, pruneItems],
);
const handleMaxSessionTurnsEvent = useCallback(
@@ -1506,9 +1512,20 @@ export const useGeminiStream = (
lastQueryRef.current = queryToSend;
lastPromptIdRef.current = prompt_id!;
// Prepend current time (and schedule if items exist) so the
// model can reason about time and scheduling in forever mode.
let queryWithContext = queryToSend;
if (
config.getIsForeverMode() &&
typeof queryWithContext === 'string'
) {
const scheduler = config.getWorkScheduler();
queryWithContext = `[${scheduler.formatScheduleSummary()}]\n\n${queryWithContext}`;
}
try {
const stream = geminiClient.sendMessageStream(
queryToSend,
queryWithContext,
abortSignal,
prompt_id!,
undefined,
@@ -1946,6 +1963,66 @@ export const useGeminiStream = (
storage,
]);
// Idle hook timer: fires after idleTimeout seconds of no activity.
// The timeout is read from the Idle hook definitions themselves.
// If hooks exist but don't declare a timeout, fall back to 300 seconds.
const DEFAULT_IDLE_TIMEOUT = 300;
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// Clear any existing timer
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}
if (streamingState !== StreamingState.Idle || !config.getEnableHooks()) {
return;
}
// Derive timeout from registered Idle hook definitions.
const hookSystem = config.getHookSystem();
const idleHooks = hookSystem
?.getAllHooks()
.filter((h) => h.eventName === 'Idle' && h.enabled);
if (!idleHooks || idleHooks.length === 0) {
return;
}
// Use the max idleTimeout declared by any Idle hook, or the default.
const declaredTimeouts = idleHooks
.map((h) => h.idleTimeout)
.filter((t): t is number => typeof t === 'number' && t > 0);
const idleTimeoutSeconds =
declaredTimeouts.length > 0
? Math.max(...declaredTimeouts)
: DEFAULT_IDLE_TIMEOUT;
const startTime = Date.now();
idleTimerRef.current = setTimeout(async () => {
if (!hookSystem) return;
const elapsed = Math.round((Date.now() - startTime) / 1000);
try {
const result = await hookSystem.fireIdleEvent(elapsed);
const prompt = result?.finalOutput?.hookSpecificOutput?.['prompt'];
if (typeof prompt === 'string' && prompt.trim()) {
// Auto-submit the prompt returned by the hook
void submitQuery(prompt);
}
} catch {
// Idle hook failures are non-fatal
}
}, idleTimeoutSeconds * 1000);
return () => {
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}
};
}, [streamingState, config, submitQuery]);
const lastOutputTime = Math.max(
lastToolOutputTime,
lastShellOutputTime,
@@ -7,7 +7,7 @@
import { describe, it, expect } from 'vitest';
import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { useHistory } from './useHistoryManager.js';
import { useHistory, PRUNE_KEEP_COUNT } from './useHistoryManager.js';
import type { HistoryItem } from '../types.js';
describe('useHistoryManager', () => {
@@ -255,4 +255,82 @@ describe('useHistoryManager', () => {
expect(result.current.history[0].type).toBe('info');
});
});
describe('pruneItems', () => {
it('should prune history to PRUNE_KEEP_COUNT + 1 (marker) when over limit', () => {
const { result } = renderHook(() => useHistory());
const itemCount = PRUNE_KEEP_COUNT + 20;
act(() => {
for (let i = 0; i < itemCount; i++) {
result.current.addItem({
type: 'user',
text: `Message ${i}`,
});
}
});
expect(result.current.history).toHaveLength(itemCount);
act(() => {
result.current.pruneItems();
});
// PRUNE_KEEP_COUNT items + 1 prune marker
expect(result.current.history).toHaveLength(PRUNE_KEEP_COUNT + 1);
// First item should be the prune marker
expect(result.current.history[0].type).toBe('info');
expect(result.current.history[0].text).toContain('pruned');
// Last item should be the most recent message
expect(
result.current.history[result.current.history.length - 1].text,
).toBe(`Message ${itemCount - 1}`);
});
it('should be a no-op when history is under the threshold', () => {
const { result } = renderHook(() => useHistory());
const itemCount = 10;
act(() => {
for (let i = 0; i < itemCount; i++) {
result.current.addItem({
type: 'user',
text: `Message ${i}`,
});
}
});
const historyBefore = result.current.history;
act(() => {
result.current.pruneItems();
});
// Should be unchanged (same reference)
expect(result.current.history).toBe(historyBefore);
expect(result.current.history).toHaveLength(itemCount);
});
it('should be a no-op when history is exactly at the threshold', () => {
const { result } = renderHook(() => useHistory());
act(() => {
for (let i = 0; i < PRUNE_KEEP_COUNT; i++) {
result.current.addItem({
type: 'user',
text: `Message ${i}`,
});
}
});
const historyBefore = result.current.history;
act(() => {
result.current.pruneItems();
});
expect(result.current.history).toBe(historyBefore);
expect(result.current.history).toHaveLength(PRUNE_KEEP_COUNT);
});
});
});
+28 -1
View File
@@ -8,6 +8,12 @@ import { useState, useRef, useCallback, useMemo } from 'react';
import type { HistoryItem } from '../types.js';
import type { ChatRecordingService } from '@google/gemini-cli-core/src/services/chatRecordingService.js';
/**
* Number of history items to keep when pruning after context compression.
* Exported for testing purposes.
*/
export const PRUNE_KEEP_COUNT = 50;
// Type for the updater function passed to updateHistoryItem
type HistoryItemUpdater = (
prevItem: HistoryItem,
@@ -26,6 +32,7 @@ export interface UseHistoryManagerReturn {
) => void;
clearItems: () => void;
loadHistory: (newHistory: HistoryItem[]) => void;
pruneItems: () => void;
}
/**
@@ -156,6 +163,25 @@ export function useHistory({
messageIdCounterRef.current = 0;
}, []);
// Prunes old history items, keeping only the most recent PRUNE_KEEP_COUNT.
// Intended to be called after context compression to free memory in
// long-running sessions.
const pruneItems = useCallback(() => {
setHistory((prevHistory) => {
if (prevHistory.length <= PRUNE_KEEP_COUNT) {
return prevHistory;
}
const kept = prevHistory.slice(-PRUNE_KEEP_COUNT);
const marker = {
id: getNextMessageId(Date.now()),
type: 'info',
text: `️ Earlier history was pruned after context compression.`,
} as HistoryItem;
return [marker, ...kept];
});
}, [getNextMessageId]);
return useMemo(
() => ({
history,
@@ -163,7 +189,8 @@ export function useHistory({
updateItem,
clearItems,
loadHistory,
pruneItems,
}),
[history, addItem, updateItem, clearItems, loadHistory],
[history, addItem, updateItem, clearItems, loadHistory, pruneItems],
);
}
@@ -91,6 +91,7 @@ describe('useIncludeDirsTrust', () => {
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
pruneItems: vi.fn(),
};
mockSetCustomDialog = vi.fn();
});
@@ -85,6 +85,7 @@ describe('useQuotaAndFallback', () => {
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
pruneItems: vi.fn(),
};
mockSetModelSwitchedFromQuotaError = vi.fn();
mockOnShowAuthSelection = vi.fn();
@@ -78,12 +78,17 @@ export const useSessionBrowser = (
// We've loaded it; tell the UI about it.
setIsSessionBrowserOpen(false);
const compressionIndex = conversation.lastCompressionIndex;
const historyData = convertSessionToHistoryFormats(
conversation.messages,
compressionIndex,
);
await onLoadHistory(
historyData.uiHistory,
convertSessionToClientHistory(conversation.messages),
convertSessionToClientHistory(
conversation.messages,
compressionIndex,
),
resumedSessionData,
);
} catch (error) {
@@ -33,6 +33,7 @@ describe('useSessionResume', () => {
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
pruneItems: vi.fn(),
});
let mockHistoryManager: UseHistoryManagerReturn;
@@ -529,5 +530,60 @@ describe('useSessionResume', () => {
// But UI history should have both
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2);
});
it('should restore scheduled work from resumed session data', async () => {
const mockRestore = vi.fn();
const configWithScheduler = {
...mockConfig,
getWorkScheduler: vi.fn().mockReturnValue({
restore: mockRestore,
}),
};
const scheduledWork = [
{
id: 'test-1',
prompt: 'check status',
fireAt: new Date(Date.now() + 60_000).toISOString(),
createdAt: new Date().toISOString(),
},
];
const conversation: ConversationRecord = {
sessionId: 'auto-resume-scheduled',
projectHash: 'project-123',
startTime: '2025-01-01T00:00:00Z',
lastUpdated: '2025-01-01T01:00:00Z',
messages: [
{
id: 'msg-1',
timestamp: '2025-01-01T00:01:00Z',
content: 'Hello',
type: 'user',
},
] as MessageRecord[],
scheduledWork,
};
await act(async () => {
renderHook(() =>
useSessionResume({
...getDefaultProps(),
config: configWithScheduler as unknown as Config,
resumedSessionData: {
conversation,
filePath: '/path/to/session.json',
},
}),
);
});
await waitFor(() => {
expect(mockHistoryManager.clearItems).toHaveBeenCalled();
});
expect(configWithScheduler.getWorkScheduler).toHaveBeenCalled();
expect(mockRestore).toHaveBeenCalledWith(scheduledWork);
});
});
});
+17 -1
View File
@@ -83,6 +83,16 @@ export function useSessionResume({
workspaceContext.addDirectories(resumedData.conversation.directories);
}
// Restore scheduled work items from the resumed session.
// Past-due items fire immediately; future items get timers re-armed.
if (
resumedData.conversation.scheduledWork &&
resumedData.conversation.scheduledWork.length > 0
) {
const scheduler = config.getWorkScheduler();
scheduler.restore(resumedData.conversation.scheduledWork);
}
// Give the history to the Gemini client.
await config.getGeminiClient()?.resumeChat(clientHistory, resumedData);
} catch (error) {
@@ -109,12 +119,18 @@ export function useSessionResume({
!hasLoadedResumedSession.current
) {
hasLoadedResumedSession.current = true;
const compressionIndex =
resumedSessionData.conversation.lastCompressionIndex;
const historyData = convertSessionToHistoryFormats(
resumedSessionData.conversation.messages,
compressionIndex,
);
void loadHistoryForResume(
historyData.uiHistory,
convertSessionToClientHistory(resumedSessionData.conversation.messages),
convertSessionToClientHistory(
resumedSessionData.conversation.messages,
compressionIndex,
),
resumedSessionData,
);
}
+4
View File
@@ -23,6 +23,8 @@ export enum AppEvent {
PasteTimeout = 'paste-timeout',
TerminalBackground = 'terminal-background',
TransientMessage = 'transient-message',
ExternalMessage = 'external-message',
A2AListenerStarted = 'a2a-listener-started',
}
export interface AppEvents {
@@ -32,6 +34,8 @@ export interface AppEvents {
[AppEvent.PasteTimeout]: never[];
[AppEvent.TerminalBackground]: [string];
[AppEvent.TransientMessage]: [TransientMessagePayload];
[AppEvent.ExternalMessage]: [string];
[AppEvent.A2AListenerStarted]: [number];
}
export const appEvents = new EventEmitter<AppEvents>();
+13 -1
View File
@@ -539,12 +539,24 @@ export class SessionSelector {
*/
export function convertSessionToHistoryFormats(
messages: ConversationRecord['messages'],
startIndex?: number,
): {
uiHistory: HistoryItemWithoutId[];
} {
const uiHistory: HistoryItemWithoutId[] = [];
for (const msg of messages) {
const hasCompressedHistory =
startIndex != null && startIndex > 0 && startIndex < messages.length;
const slice = hasCompressedHistory ? messages.slice(startIndex) : messages;
if (hasCompressedHistory) {
uiHistory.push({
type: MessageType.INFO,
text: `️ Earlier history (${startIndex} messages) was compressed. Showing post-compression messages only.`,
});
}
for (const msg of slice) {
// Add thoughts if present
if (msg.type === 'gemini' && msg.thoughts && msg.thoughts.length > 0) {
for (const thought of msg.thoughts) {