mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-23 09:47:45 -07:00
feat: implement session-scoped scheduled tasks and /loop command
This commit is contained in:
@@ -83,7 +83,9 @@ import {
|
||||
logBillingEvent,
|
||||
ApiKeyUpdatedEvent,
|
||||
type InjectionSource,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
cronSchedulerService,
|
||||
type ScheduledTask} from '@google/gemini-cli-core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import process from 'node:process';
|
||||
import { useHistory } from './hooks/useHistoryManager.js';
|
||||
@@ -1211,6 +1213,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isMcpReady,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleTaskDue = (task: ScheduledTask) => {
|
||||
addMessage(task.prompt);
|
||||
};
|
||||
cronSchedulerService.on('task_due', handleTaskDue);
|
||||
return () => {
|
||||
cronSchedulerService.off('task_due', handleTaskDue);
|
||||
};
|
||||
}, [addMessage]);
|
||||
|
||||
cancelHandlerRef.current = useCallback(
|
||||
(shouldRestorePrompt: boolean = true) => {
|
||||
if (isToolAwaitingConfirmation(pendingHistoryItems)) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { loopCommand } from './loopCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { cronSchedulerService } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
cronSchedulerService: {
|
||||
scheduleTask: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('loopCommand', () => {
|
||||
let mockContext: ReturnType<typeof createMockCommandContext>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should print an error if no args are provided', async () => {
|
||||
mockContext.invocation!.args = ' ';
|
||||
await loopCommand.action!(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining('Please provide a prompt'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should default to 10m if no interval is provided', async () => {
|
||||
mockContext.invocation!.args = 'check the build';
|
||||
vi.mocked(cronSchedulerService.scheduleTask).mockReturnValue('abc12345');
|
||||
|
||||
await loopCommand.action!(mockContext, '');
|
||||
|
||||
expect(cronSchedulerService.scheduleTask).toHaveBeenCalledWith(
|
||||
'10m',
|
||||
'check the build',
|
||||
true,
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('every 10m'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse leading interval', async () => {
|
||||
mockContext.invocation!.args = '5m check the build';
|
||||
vi.mocked(cronSchedulerService.scheduleTask).mockReturnValue('def56789');
|
||||
|
||||
await loopCommand.action!(mockContext, '');
|
||||
|
||||
expect(cronSchedulerService.scheduleTask).toHaveBeenCalledWith(
|
||||
'5m',
|
||||
'check the build',
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle scheduling errors', async () => {
|
||||
mockContext.invocation!.args = 'invalid check the build';
|
||||
vi.mocked(cronSchedulerService.scheduleTask).mockImplementation(() => {
|
||||
throw new Error('Invalid format');
|
||||
});
|
||||
|
||||
await loopCommand.action!(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining(
|
||||
'Failed to schedule task: Invalid format',
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import { cronSchedulerService } from '@google/gemini-cli-core';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
export const loopCommand: SlashCommand = {
|
||||
name: 'loop',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Schedules a repeating prompt (e.g., /loop 5m check the build)',
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
const args = context.invocation?.args?.trim() || '';
|
||||
if (!args) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: 'Please provide a prompt to loop. Example: /loop 5m check the build',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Default to 10 minutes if no interval is provided
|
||||
let intervalString = '10m';
|
||||
|
||||
// Check if the first word is an interval
|
||||
const match = args.match(/^(\d+[smhd])\s+(.*)/i);
|
||||
let prompt = args;
|
||||
|
||||
if (match) {
|
||||
intervalString = match[1].toLowerCase();
|
||||
prompt = match[2].trim();
|
||||
}
|
||||
|
||||
try {
|
||||
const id = cronSchedulerService.scheduleTask(
|
||||
intervalString,
|
||||
prompt,
|
||||
true,
|
||||
);
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Scheduled recurring task \`${id}\` to run \`${prompt}\` every ${intervalString}.`,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Failed to schedule task: ${message}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user