feat: implement session-scoped scheduled tasks and /loop command

This commit is contained in:
Samee Zahid
2026-03-30 15:00:47 -07:00
parent 9cf410478c
commit 21b2b42e5e
9 changed files with 682 additions and 1 deletions
+13 -1
View File
@@ -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}`,
});
}
},
};