feat(cron): add distinct Cron message type and exit summary

- Introduce SendMessageType.Cron to differentiate cron-triggered prompts
  from user queries
- Skip UserPromptSubmit hook for cron messages
- Add getExitSummary() to display active loops when session ends
- Add tests for exit summary functionality

This improves cron loop handling by treating scheduled prompts
differently from user-initiated queries and provides better UX
when sessions end with active loops running.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
tanzhenxin 2026-03-30 17:00:19 +08:00
parent 314a9ded78
commit aa454a5a72
5 changed files with 94 additions and 7 deletions

View file

@ -92,6 +92,8 @@ export enum SendMessageType {
ToolResult = 'toolResult',
Retry = 'retry',
Hook = 'hook',
/** Cron-fired prompt. Behaves like UserQuery but skips UserPromptSubmit hook. */
Cron = 'cron',
}
export interface SendMessageOptions {
@ -465,7 +467,12 @@ export class GeminiClient {
// Fire UserPromptSubmit hook through MessageBus (only if hooks are enabled)
const hooksEnabled = this.config.getEnableHooks();
const messageBus = this.config.getMessageBus();
if (messageType !== SendMessageType.Retry && hooksEnabled && messageBus) {
if (
messageType !== SendMessageType.Retry &&
messageType !== SendMessageType.Cron &&
hooksEnabled &&
messageBus
) {
const promptText = partToString(request);
const response = await messageBus.request<
HookExecutionRequest,
@ -507,7 +514,10 @@ export class GeminiClient {
}
}
if (messageType === SendMessageType.UserQuery) {
if (
messageType === SendMessageType.UserQuery ||
messageType === SendMessageType.Cron
) {
this.loopDetector.reset(prompt_id);
this.lastPromptId = prompt_id;
@ -605,7 +615,10 @@ export class GeminiClient {
// append system reminders to the request
let requestToSent = await flatMapTextParts(request, async (text) => [text]);
if (messageType === SendMessageType.UserQuery) {
if (
messageType === SendMessageType.UserQuery ||
messageType === SendMessageType.Cron
) {
const systemReminders = [];
// add subagent system reminder if there are subagents

View file

@ -203,6 +203,44 @@ describe('CronScheduler', () => {
});
});
describe('getExitSummary', () => {
it('returns null when no jobs', () => {
expect(scheduler.getExitSummary()).toBeNull();
});
it('returns summary with single job', () => {
scheduler.create('*/5 * * * *', 'check the build', true);
const summary = scheduler.getExitSummary()!;
expect(summary).toContain('1 active loop cancelled:');
expect(summary).toContain('Every 5 minutes');
expect(summary).toContain('check the build');
});
it('returns summary with multiple jobs', () => {
scheduler.create('*/5 * * * *', 'check the build', true);
scheduler.create('*/30 * * * *', 'check PR reviews', true);
const summary = scheduler.getExitSummary()!;
expect(summary).toContain('2 active loops cancelled:');
expect(summary).toContain('check the build');
expect(summary).toContain('check PR reviews');
});
it('truncates long prompts', () => {
const longPrompt = 'a'.repeat(100);
scheduler.create('*/1 * * * *', longPrompt, true);
const summary = scheduler.getExitSummary()!;
expect(summary).toContain('...');
// Should not contain the full 100-char prompt
expect(summary).not.toContain(longPrompt);
});
it('returns null after all jobs are deleted', () => {
const job = scheduler.create('*/1 * * * *', 'temp', true);
scheduler.delete(job.id);
expect(scheduler.getExitSummary()).toBeNull();
});
});
describe('destroy', () => {
it('stops and clears all jobs', () => {
scheduler.create('*/1 * * * *', 'a', true);

View file

@ -4,6 +4,7 @@
*/
import { matches, nextFireTime } from '../utils/cronParser.js';
import { humanReadableCron } from '../utils/cronDisplay.js';
const MAX_JOBS = 50;
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
@ -224,6 +225,27 @@ export class CronScheduler {
}
}
/**
* Returns a human-readable summary of active jobs for display on session
* exit. Returns null if there are no active jobs.
*/
getExitSummary(): string | null {
if (this.jobs.size === 0) return null;
const count = this.jobs.size;
const lines = [
`Session ending. ${count} active loop${count === 1 ? '' : 's'} cancelled:`,
];
for (const job of this.jobs.values()) {
const schedule = humanReadableCron(job.cronExpr);
// Truncate long prompts
const prompt =
job.prompt.length > 60 ? job.prompt.slice(0, 57) + '...' : job.prompt;
lines.push(` - [${job.id}] ${schedule}: ${prompt}`);
}
return lines.join('\n');
}
/**
* Clears all jobs and stops the scheduler.
*/