mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-23 03:24:42 -07:00
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:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -513,6 +513,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
forever: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ describe('App', () => {
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
pruneItems: vi.fn(),
|
||||
},
|
||||
history: [],
|
||||
pendingHistoryItems: [],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user