diff --git a/.changeset/visible-cron-reminders.md b/.changeset/visible-cron-reminders.md new file mode 100644 index 00000000..bcc52d9f --- /dev/null +++ b/.changeset/visible-cron-reminders.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Render scheduled reminders distinctly in the TUI, expose cron fired events to SDK clients, and report cron fire times with local timezone offsets. diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index e639aed0..a542fb27 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -394,6 +394,7 @@ function runPromptTurn( case 'compaction.cancelled': case 'compaction.completed': case 'compaction.started': + case 'cron.fired': case 'mcp.server.status': case 'session.meta.updated': case 'skill.activated': @@ -403,6 +404,7 @@ function runPromptTurn( case 'tool.list.updated': case 'turn.started': case 'turn.step.completed': + case 'warning': return; } }); diff --git a/apps/kimi-code/src/tui/components/messages/cron-message.ts b/apps/kimi-code/src/tui/components/messages/cron-message.ts new file mode 100644 index 00000000..075ce337 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/cron-message.ts @@ -0,0 +1,71 @@ +import type { Component } from '@earendil-works/pi-tui'; +import { Spacer, Text, visibleWidth } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import type { ColorPalette } from '#/tui/theme/colors'; +import type { CronTranscriptData } from '#/tui/types'; + +export class CronMessageComponent implements Component { + private readonly spacer = new Spacer(1); + private readonly title: string; + private readonly detail: string | undefined; + private readonly titleColor: string; + private readonly promptText: Text; + + constructor( + prompt: string, + data: CronTranscriptData, + private readonly colors: ColorPalette, + ) { + const missed = data.missedCount !== undefined; + this.title = missed ? 'Missed scheduled reminders' : 'Scheduled reminder fired'; + this.detail = cronDetail(data); + this.titleColor = data.stale === true || missed ? colors.warning : colors.accent; + this.promptText = new Text(chalk.hex(colors.text)(prompt), 0, 0); + } + + invalidate(): void { + this.promptText.invalidate(); + } + + render(width: number): string[] { + const bullet = chalk.hex(this.titleColor).bold(STATUS_BULLET); + const bulletWidth = visibleWidth(bullet); + const contentWidth = Math.max(1, width - bulletWidth); + const lines: string[] = []; + + for (const line of this.spacer.render(width)) { + lines.push(line); + } + + const title = chalk.hex(this.titleColor).bold(this.title); + lines.push(`${bullet}${title}`); + + if (this.detail !== undefined) { + lines.push(`${' '.repeat(bulletWidth)}${chalk.hex(this.colors.textDim)(this.detail)}`); + } + + const promptLines = this.promptText.render(contentWidth); + for (const line of promptLines) { + lines.push(`${' '.repeat(bulletWidth)}${line}`); + } + + return lines; + } +} + +function cronDetail(data: CronTranscriptData): string | undefined { + const parts: string[] = []; + if (data.cron !== undefined && data.cron.length > 0) parts.push(data.cron); + if (data.jobId !== undefined && data.jobId.length > 0) parts.push(`job ${data.jobId}`); + if (data.recurring === false) parts.push('one-shot'); + if (data.coalescedCount !== undefined && data.coalescedCount > 1) { + parts.push(`${String(data.coalescedCount)} fires coalesced`); + } + if (data.missedCount !== undefined) { + parts.push(`${String(data.missedCount)} missed`); + } + if (data.stale === true) parts.push('final delivery'); + return parts.length > 0 ? parts.join(' | ') : undefined; +} diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 0e310ba9..e3e35766 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -9,6 +9,7 @@ import type { CompactionCancelledEvent, CompactionCompletedEvent, CompactionStartedEvent, + CronFiredEvent, ErrorEvent, Event, HookResultEvent, @@ -206,6 +207,7 @@ export class SessionEventHandler { case 'background.task.updated': case 'background.task.terminated': this.handleBackgroundTaskEvent(event); break; + case 'cron.fired': this.handleCronFired(event); break; case 'mcp.server.status': this.renderMcpServerStatus(event.server); break; case 'tool.list.updated': break; default: break; @@ -283,7 +285,9 @@ export class SessionEventHandler { case 'compaction.cancelled': case 'compaction.completed': case 'compaction.started': + case 'cron.fired': case 'error': + case 'warning': case 'session.meta.updated': case 'skill.activated': case 'subagent.completed': @@ -319,6 +323,24 @@ export class SessionEventHandler { }); } + private handleCronFired(event: CronFiredEvent): void { + this.host.streamingUI.flushNow(); + this.host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'cron', + turnId: this.host.streamingUI.getTurnContext().turnId, + renderMode: 'plain', + content: event.prompt, + cronData: { + jobId: event.origin.jobId, + cron: event.origin.cron, + recurring: event.origin.recurring, + coalescedCount: event.origin.coalescedCount, + stale: event.origin.stale, + }, + }); + } + private handleTurnEnd(_event: TurnEndedEvent, sendQueued: (item: QueuedMessage) => void): void { void _event; this.host.streamingUI.flushNow(); diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index f806cd81..0afc34a6 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -203,10 +203,12 @@ export class SessionReplayRenderer { if (message.origin?.kind === 'injection') { return; } - // WHY: cron fires are not user turns (see isReplayUserTurnRecord); skip - // visual render and turn advance so the raw envelope never - // surfaces in the resumed transcript. - if (message.origin?.kind === 'cron_job' || message.origin?.kind === 'cron_missed') { + if (message.origin?.kind === 'cron_job') { + this.renderCronJob(context, message); + return; + } + if (message.origin?.kind === 'cron_missed') { + this.renderCronMissed(context, message); return; } @@ -332,6 +334,37 @@ export class SessionReplayRenderer { ); } + private renderCronJob(context: ReplayRenderContext, message: ContextMessage): void { + if (message.origin?.kind !== 'cron_job') return; + this.flushAssistant(context); + this.host.appendTranscriptEntry({ + ...replayEntry( + context, + 'cron', + extractCronPrompt(contentPartsToText(message.content)), + 'plain', + ), + cronData: { + jobId: message.origin.jobId, + cron: message.origin.cron, + recurring: message.origin.recurring, + coalescedCount: message.origin.coalescedCount, + stale: message.origin.stale, + }, + }); + } + + private renderCronMissed(context: ReplayRenderContext, message: ContextMessage): void { + if (message.origin?.kind !== 'cron_missed') return; + this.flushAssistant(context); + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'cron', stripCronEnvelope(contentPartsToText(message.content)), 'plain'), + cronData: { + missedCount: message.origin.count, + }, + }); + } + private renderPermissionUpdate(context: ReplayRenderContext, mode: PermissionMode): void { if (mode === 'yolo') { this.host.appendTranscriptEntry( @@ -471,3 +504,26 @@ export class SessionReplayRenderer { sessionEventHandler.backgroundAgentMetadata.delete(meta.agentId); } } + +function extractCronPrompt(text: string): string { + const open = '\n'; + const close = '\n'; + const start = text.indexOf(open); + const end = text.lastIndexOf(close); + if (start >= 0 && end >= start + open.length) { + return text.slice(start + open.length, end); + } + return stripCronEnvelope(text); +} + +function stripCronEnvelope(text: string): string { + const lines = text.split('\n'); + if ( + lines.length >= 2 && + lines[0]?.startsWith('' + ) { + return lines.slice(1, -1).join('\n'); + } + return text; +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index ad69644d..915509a2 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -65,6 +65,7 @@ import { TasksBrowserController } from './controllers/tasks-browser'; import { FileMentionProvider } from './components/editor/file-mention-provider'; import { AssistantMessageComponent } from './components/messages/assistant-message'; import { BackgroundAgentStatusComponent } from './components/messages/background-agent-status'; +import { CronMessageComponent } from './components/messages/cron-message'; import { SkillActivationComponent } from './components/messages/skill-activation'; import { NoticeMessageComponent, @@ -1162,6 +1163,12 @@ export class KimiTUI { entry.skillArgs, this.state.theme.colors, ); + case 'cron': + return new CronMessageComponent( + entry.content, + entry.cronData ?? {}, + this.state.theme.colors, + ); case 'assistant': { const component = new AssistantMessageComponent( this.state.theme.markdownTheme, diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 7d18bead..c1f8e8d5 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -95,6 +95,15 @@ export interface CompactionTranscriptData { readonly instruction?: string; } +export interface CronTranscriptData { + readonly jobId?: string; + readonly cron?: string; + readonly recurring?: boolean; + readonly coalescedCount?: number; + readonly stale?: boolean; + readonly missedCount?: number; +} + export type TranscriptEntryKind = | 'welcome' | 'user' @@ -102,7 +111,8 @@ export type TranscriptEntryKind = | 'tool_call' | 'thinking' | 'status' - | 'skill_activation'; + | 'skill_activation' + | 'cron'; export interface TranscriptEntry { id: string; @@ -115,6 +125,7 @@ export interface TranscriptEntry { toolCallData?: ToolCallBlockData; backgroundAgentStatus?: BackgroundAgentStatusData; compactionData?: CompactionTranscriptData; + cronData?: CronTranscriptData; imageAttachmentIds?: readonly number[]; skillActivationId?: string; skillName?: string; diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 40ab4e65..41bc3397 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -761,6 +761,46 @@ describe('KimiTUI message flow', () => { } }); + it('renders cron fired events as distinct transcript entries', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'cron.fired', + agentId: 'main', + sessionId: 'ses-1', + origin: { + kind: 'cron_job', + jobId: 'deadbeef', + cron: '* * * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }, + prompt: '提醒用户:这是每分钟提醒', + } as Event, + vi.fn(), + ); + + const entry = driver.state.transcriptEntries.at(-1); + expect(entry).toMatchObject({ + kind: 'cron', + content: '提醒用户:这是每分钟提醒', + cronData: { + jobId: 'deadbeef', + cron: '* * * * *', + coalescedCount: 1, + stale: false, + }, + }); + + const transcript = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); + expect(transcript).toContain('Scheduled reminder fired'); + expect(transcript).toContain('* * * * *'); + expect(transcript).toContain('提醒用户:这是每分钟提醒'); + expect(transcript).not.toContain(' { vi.useFakeTimers(); try { diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 6ca45971..a63a9790 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -372,9 +372,9 @@ describe('KimiTUI resume message replay', () => { ]); }); - it('skips cron_job origin records during replay', async () => { + it('renders cron_job origin records during replay without exposing raw XML', async () => { const cronFire = - '\nrun nightly\n'; + '\n\nrun nightly\n\n'; const driver = await replayIntoDriver([ message('user', [{ type: 'text', text: 'real prompt' }]), message('assistant', [{ type: 'text', text: 'real answer' }]), @@ -392,14 +392,21 @@ describe('KimiTUI resume message replay', () => { const transcript = driver.state.transcriptContainer.render(120).join('\n'); expect(transcript).not.toContain(' entry.kind === 'user') .map((entry) => entry.content), ).toEqual(['real prompt']); + expect( + driver.state.transcriptEntries + .filter((entry) => entry.kind === 'cron') + .map((entry) => entry.content), + ).toEqual(['run nightly']); }); - it('skips cron_missed origin records during replay', async () => { + it('renders cron_missed origin records during replay without exposing raw XML', async () => { const cronMissed = '\n3 one-shot tasks missed while offline\n'; const driver = await replayIntoDriver([ @@ -412,12 +419,18 @@ describe('KimiTUI resume message replay', () => { const transcript = driver.state.transcriptContainer.render(120).join('\n'); expect(transcript).not.toContain(' entry.kind === 'user') .map((entry) => entry.content), ).toEqual(['real prompt']); + expect( + driver.state.transcriptEntries + .filter((entry) => entry.kind === 'cron') + .map((entry) => entry.content), + ).toEqual(['3 one-shot tasks missed while offline']); }); it('renders user-slash skill activation once without exposing injected prompt text', async () => { diff --git a/packages/agent-core/src/agent/cron/manager.ts b/packages/agent-core/src/agent/cron/manager.ts index e92f2a76..3ba937bd 100644 --- a/packages/agent-core/src/agent/cron/manager.ts +++ b/packages/agent-core/src/agent/cron/manager.ts @@ -157,7 +157,9 @@ export class CronManager { source: () => this.store.list(), isIdle: () => !agent.turn.hasActiveTurn, isKilled: () => process.env['KIMI_DISABLE_CRON'] === '1', - onFire: (task, ctx) => this.handleFire(task, ctx), + onFire: (task, ctx) => { + this.handleFire(task, ctx); + }, removeOneShot: (id) => { this.removeTasks([id]); }, @@ -275,8 +277,8 @@ export class CronManager { const next = prev .catch(() => {}) .then(() => work()) - .catch((err: unknown) => { - this.agent.log?.warn?.('cron persist failed', err); + .catch((error: unknown) => { + this.agent.log?.warn?.('cron persist failed', error); }) .finally(() => { if (this.persistQueues.get(id) === next) { @@ -414,6 +416,11 @@ export class CronManager { text: renderCronFireXml(origin, task.prompt), }, ]; + this.agent.emitEvent({ + type: 'cron.fired', + origin, + prompt: task.prompt, + }); const turnId = this.agent.turn.steer(content, origin); this.agent.telemetry.track(CRON_FIRED, { recurring: task.recurring !== false, diff --git a/packages/agent-core/src/rpc/events.ts b/packages/agent-core/src/rpc/events.ts index b9a48806..5d8f99e3 100644 --- a/packages/agent-core/src/rpc/events.ts +++ b/packages/agent-core/src/rpc/events.ts @@ -1,6 +1,6 @@ import type { FinishReason, TokenUsage } from '@moonshot-ai/kosong'; -import type { PromptOrigin } from '../agent/context'; +import type { CronJobOrigin, PromptOrigin } from '../agent/context'; import type { KimiErrorPayload } from '../errors'; import type { PermissionMode } from '../agent/permission'; import type { SkillSource } from '../skill'; @@ -249,6 +249,12 @@ export interface BackgroundTaskTerminatedEvent { readonly info: BackgroundTaskInfo; } +export interface CronFiredEvent { + readonly type: 'cron.fired'; + readonly origin: CronJobOrigin; + readonly prompt: string; +} + export type ToolListUpdatedReason = 'mcp.connected' | 'mcp.disconnected' | 'mcp.failed'; export interface ToolListUpdatedEvent { @@ -300,6 +306,7 @@ export type AgentEvent = | CompactionCompletedEvent | BackgroundTaskStartedEvent | BackgroundTaskUpdatedEvent - | BackgroundTaskTerminatedEvent; + | BackgroundTaskTerminatedEvent + | CronFiredEvent; export type Event = AgentEvent & { agentId: string; sessionId: string }; diff --git a/packages/agent-core/src/tools/cron/cron-create.md b/packages/agent-core/src/tools/cron/cron-create.md index fbd6598b..92e80bc1 100644 --- a/packages/agent-core/src/tools/cron/cron-create.md +++ b/packages/agent-core/src/tools/cron/cron-create.md @@ -81,4 +81,8 @@ to the resumed session id, not to the working directory. ## Returned fields `id` (8-hex), `humanSchedule` (English summary), `recurring`, -`nextFireAt` (ISO timestamp or null). `id` is needed by `CronDelete`. +`nextFireAt` (local ISO timestamp with numeric offset, or null). `id` is needed by `CronDelete`. + +## Tell the user how to cancel or modify + +After successfully creating a task, proactively tell the user how they can cancel or modify it later. Users have no direct `/cron` command or self-service UI to manage reminders themselves; they must ask the model to make changes (e.g. "cancel my 9am reminder" or "change my daily check to 10am"). Include the task `id` in your message so the user can reference it. diff --git a/packages/agent-core/src/tools/cron/cron-create.ts b/packages/agent-core/src/tools/cron/cron-create.ts index 81bfd093..a267ee16 100644 --- a/packages/agent-core/src/tools/cron/cron-create.ts +++ b/packages/agent-core/src/tools/cron/cron-create.ts @@ -41,6 +41,7 @@ import { jitteredNextCronRunMs, oneShotJitteredNextCronRunMs, } from './jitter'; +import { formatLocalIsoWithOffset } from './time-format'; import CRON_CREATE_DESCRIPTION from './cron-create.md'; // ── Constants ──────────────────────────────────────────────────────── @@ -224,9 +225,9 @@ export class CronCreateTool implements BuiltinTool { isError: true, output: `One-shot cron ${JSON.stringify( normalizedCron, - )} would not fire until ${new Date( + )} would not fire until ${formatLocalIsoWithOffset( firstFire, - ).toISOString()} (more than a year out). If you meant "today" or a near date, the pinned day/month has already passed this year — pick a future date or use wildcards.`, + )} (more than a year out). If you meant "today" or a near date, the pinned day/month has already passed this year — pick a future date or use wildcards.`, }; } } @@ -320,7 +321,7 @@ function formatOutput(o: CronCreateOutput): string { `humanSchedule: ${o.humanSchedule}`, `recurring: ${String(o.recurring)}`, `nextFireAt: ${ - o.nextFireAt === null ? 'null' : new Date(o.nextFireAt).toISOString() + o.nextFireAt === null ? 'null' : formatLocalIsoWithOffset(o.nextFireAt) }`, ]; return lines.join('\n'); diff --git a/packages/agent-core/src/tools/cron/cron-delete.md b/packages/agent-core/src/tools/cron/cron-delete.md index c1989092..ebc63b81 100644 --- a/packages/agent-core/src/tools/cron/cron-delete.md +++ b/packages/agent-core/src/tools/cron/cron-delete.md @@ -33,6 +33,10 @@ pending). Guidelines: +- Users have no direct `/cron` command or self-service UI to delete + tasks themselves; they must ask the model to cancel a reminder. + When deleting on behalf of a user, confirm the action and report + the result plainly. - Cron deletion is irreversible — there is no undo. If you delete the wrong task, you must re-create it with `CronCreate`. - If the model is unsure which id is current (e.g. after a context diff --git a/packages/agent-core/src/tools/cron/cron-list.md b/packages/agent-core/src/tools/cron/cron-list.md index bcf117e5..f80d60d0 100644 --- a/packages/agent-core/src/tools/cron/cron-list.md +++ b/packages/agent-core/src/tools/cron/cron-list.md @@ -19,13 +19,13 @@ Each record carries: `…(truncated)` if longer. Use this to recall what a task is for after a context compaction, and as the source for the `CronCreate` refresh ritual. -- `nextFireAt` — ISO timestamp of the next fire **after jitter has - been applied**. The actual fire may land slightly before or after a - round `:00` / `:30` minute mark due to herd-avoidance jitter; this - is the value the scheduler will compare against, so it reflects - what will really happen. `null` if the expression has no fire in - the next 5 years (should not happen for tasks created through - `CronCreate`, which validates). +- `nextFireAt` — local ISO timestamp with an explicit numeric offset + for the next fire **after jitter has been applied**. The actual fire + may land slightly before or after a round `:00` / `:30` minute mark + due to herd-avoidance jitter; this is the value the scheduler will + compare against, so it reflects what will really happen. `null` if + the expression has no fire in the next 5 years (should not happen + for tasks created through `CronCreate`, which validates). - `recurring` — `true` for cadenced jobs, `false` for one-shots. - `ageDays` — `(now - createdAt) / day`, two decimal places. Useful when deciding whether a long-running cron is still relevant. @@ -43,6 +43,9 @@ Guidelines: - This tool is read-only and never mutates state, so it is always safe to call (including in plan mode). +- Users cannot directly manage cron tasks themselves; if they want to + cancel or modify a schedule, route the request through the model + (i.e. call `CronDelete` or `CronCreate` on their behalf). - The empty case returns `cron_jobs: 0\nNo cron jobs scheduled.`. Cron tasks survive a `kimi resume` of the same session but do not bleed into new sessions. diff --git a/packages/agent-core/src/tools/cron/cron-list.ts b/packages/agent-core/src/tools/cron/cron-list.ts index 717f9276..10ca5433 100644 --- a/packages/agent-core/src/tools/cron/cron-list.ts +++ b/packages/agent-core/src/tools/cron/cron-list.ts @@ -14,7 +14,8 @@ * - `humanSchedule` — best-effort plain-English rendering via * `cronToHuman`; falls back to the raw `cron` * string if the expression can't be parsed. - * - `nextFireAt` — post-jitter ISO timestamp, or the literal + * - `nextFireAt` — post-jitter local ISO timestamp with offset, + * or the literal * string `null` when there is no fire in the * 5-year window (or the expression is malformed). * This is the same jittered value `CronCreate` @@ -49,6 +50,7 @@ import { cronToHuman, parseCronExpression, } from './cron-expr'; +import { formatLocalIsoWithOffset } from './time-format'; import type { CronTask } from './types'; import CRON_LIST_DESCRIPTION from './cron-list.md'; @@ -144,7 +146,7 @@ export class CronListTool implements BuiltinTool { // slot in the current period. const nextFireMs = this.manager.getNextFireForTask(task.id); if (nextFireMs !== null) { - nextFireAtIso = new Date(nextFireMs).toISOString(); + nextFireAtIso = formatLocalIsoWithOffset(nextFireMs); } } catch { // Malformed cron string — leave humanSchedule as the raw diff --git a/packages/agent-core/src/tools/cron/time-format.ts b/packages/agent-core/src/tools/cron/time-format.ts new file mode 100644 index 00000000..529fbcb1 --- /dev/null +++ b/packages/agent-core/src/tools/cron/time-format.ts @@ -0,0 +1,24 @@ +/** + * Render cron-facing timestamps in local wall time with an explicit + * numeric offset. Cron expressions are evaluated in local time, so the + * tool output should preserve that mental model while remaining + * unambiguous and parseable as ISO 8601. + */ +export function formatLocalIsoWithOffset(ms: number): string { + const date = new Date(ms); + const offsetMin = -date.getTimezoneOffset(); + const sign = offsetMin >= 0 ? '+' : '-'; + const absOffset = Math.abs(offsetMin); + const offset = `${sign}${pad(Math.floor(absOffset / 60))}:${pad(absOffset % 60)}`; + + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad( + date.getHours(), + )}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${String(date.getMilliseconds()).padStart( + 3, + '0', + )}${offset}`; +} + +function pad(n: number): string { + return String(n).padStart(2, '0'); +} diff --git a/packages/agent-core/test/agent/cron/harness/stub.ts b/packages/agent-core/test/agent/cron/harness/stub.ts index ac371d0a..8b11cd7c 100644 --- a/packages/agent-core/test/agent/cron/harness/stub.ts +++ b/packages/agent-core/test/agent/cron/harness/stub.ts @@ -10,6 +10,7 @@ import type { ContentPart } from '@moonshot-ai/kosong'; import type { Agent } from '../../../../src/agent'; import type { PromptOrigin } from '../../../../src/agent/context/types'; +import type { AgentEvent } from '../../../../src/rpc'; import type { ClockSources } from '../../../../src/tools/cron/clock'; /** @@ -30,6 +31,10 @@ export interface TelemetryCall { readonly props: unknown; } +export interface EventCall { + readonly event: AgentEvent; +} + export interface AgentStubOptions { /** Initial value of `agent.turn.hasActiveTurn`. Default false (idle). */ readonly hasActiveTurn?: boolean; @@ -46,12 +51,14 @@ export interface AgentStub { readonly agent: Agent; readonly steerCalls: SteerCall[]; readonly telemetryCalls: TelemetryCall[]; + readonly eventCalls: EventCall[]; setHasActiveTurn(v: boolean): void; } export function createAgentStub(opts: AgentStubOptions = {}): AgentStub { const steerCalls: SteerCall[] = []; const telemetryCalls: TelemetryCall[] = []; + const eventCalls: EventCall[] = []; let hasActiveTurn = opts.hasActiveTurn ?? false; // `?? 42` would collapse explicit `null` (buffered) into 42, so probe // the property's presence instead of relying on nullish coalescing. @@ -72,11 +79,19 @@ export function createAgentStub(opts: AgentStubOptions = {}): AgentStub { telemetryCalls.push({ event, props }); }, }; - const agent = { turn, telemetry, homedir: opts.homedir } as unknown as Agent; + const agent = { + turn, + telemetry, + homedir: opts.homedir, + emitEvent: (event: AgentEvent) => { + eventCalls.push({ event }); + }, + } as unknown as Agent; return { agent, steerCalls, telemetryCalls, + eventCalls, setHasActiveTurn: (v: boolean) => { hasActiveTurn = v; }, @@ -121,5 +136,8 @@ export function createClocks(initial: number = WALL_ANCHOR): ClockHarness { export function scrubCronOutput(out: string): string { return out .replaceAll(/\b[0-9a-f]{8}\b/g, '') - .replaceAll(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/g, ''); + .replaceAll( + /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}(?:Z|[+-]\d{2}:\d{2})/g, + '', + ); } diff --git a/packages/agent-core/test/agent/cron/manager.test.ts b/packages/agent-core/test/agent/cron/manager.test.ts index fa5fc8ff..5f8b331b 100644 --- a/packages/agent-core/test/agent/cron/manager.test.ts +++ b/packages/agent-core/test/agent/cron/manager.test.ts @@ -104,6 +104,18 @@ describe('CronManager', () => { // content from a future refactor). expect((text.match(/= 0 ? '+' : '-'; + const abs = Math.abs(offsetMin); + const offset = `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`; + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad( + date.getHours(), + )}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${String(date.getMilliseconds()).padStart( + 3, + '0', + )}${offset}`; +} + +function pad(n: number): string { + return String(n).padStart(2, '0'); +} + function extractApprovalRule(execution: ToolExecution): string { if (isErrorExecution(execution)) { throw new Error('expected runnable execution, got error'); @@ -131,6 +149,24 @@ describe('CronCreateTool', () => { }); }); + it('renders nextFireAt in local time with an explicit offset', async () => { + const now = new Date(2026, 4, 29, 8, 35, 0, 0).getTime(); + const { tool } = makeHarness(now); + const result = await runTool(tool, { + cron: '0 9 * * *', + prompt: 'morning', + recurring: true, + }); + + const out = assertSuccess(result); + const expected = new Date(now); + expected.setSeconds(0, 0); + expected.setMinutes(0); + expected.setHours(9); + expect(out).toContain(`nextFireAt: ${localIsoWithOffset(expected.getTime())}`); + expect(out).not.toContain('nextFireAt: 2026-05-29T01:00:00.000Z'); + }); + it('schedules a one-shot task with recurring=false in the stored record', async () => { const { manager, tool, stub } = makeHarness(); const result = await runTool(tool, { diff --git a/packages/agent-core/test/tools/cron/cron-list.test.ts b/packages/agent-core/test/tools/cron/cron-list.test.ts index c7f626c2..3b70c528 100644 --- a/packages/agent-core/test/tools/cron/cron-list.test.ts +++ b/packages/agent-core/test/tools/cron/cron-list.test.ts @@ -71,6 +71,24 @@ function assertSuccess(result: ExecutableToolResult): string { return result.output as string; } +function localIsoWithOffset(ms: number): string { + const date = new Date(ms); + const offsetMin = -date.getTimezoneOffset(); + const sign = offsetMin >= 0 ? '+' : '-'; + const abs = Math.abs(offsetMin); + const offset = `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`; + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad( + date.getHours(), + )}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${String(date.getMilliseconds()).padStart( + 3, + '0', + )}${offset}`; +} + +function pad(n: number): string { + return String(n).padStart(2, '0'); +} + describe('CronListTool', () => { beforeEach(() => { // Disable jitter so `nextFireAt` is the unmodified ideal — keeps @@ -117,6 +135,23 @@ describe('CronListTool', () => { `); }); + it('renders nextFireAt in local time with an explicit offset', async () => { + const now = new Date(2026, 4, 29, 8, 35, 0, 0).getTime(); + const { manager, tool } = makeHarness(now); + manager.store.add( + { cron: '0 9 * * *', prompt: 'morning', recurring: true }, + now, + ); + + const out = assertSuccess(await runTool(tool, {})); + const expected = new Date(now); + expected.setSeconds(0, 0); + expected.setMinutes(0); + expected.setHours(9); + expect(out).toContain(`nextFireAt: ${localIsoWithOffset(expected.getTime())}`); + expect(out).not.toContain('nextFireAt: 2026-05-29T01:00:00.000Z'); + }); + it('separates multiple records with \\n---\\n in insertion order', async () => { const { manager, tool } = makeHarness(); const nowMs = manager.clocks.wallNow(); diff --git a/packages/node-sdk/src/events.ts b/packages/node-sdk/src/events.ts index a20ec597..27a2f5e5 100644 --- a/packages/node-sdk/src/events.ts +++ b/packages/node-sdk/src/events.ts @@ -102,6 +102,8 @@ export type { BackgroundTaskTerminatedEvent, } from '@moonshot-ai/agent-core'; +export type { CronFiredEvent } from '@moonshot-ai/agent-core'; + export type MaybePromise = T | Promise; export type ApprovalHandler = (request: ApprovalRequest) => MaybePromise; diff --git a/packages/node-sdk/test/session-event-types.test.ts b/packages/node-sdk/test/session-event-types.test.ts index 37a36ba1..040a9b28 100644 --- a/packages/node-sdk/test/session-event-types.test.ts +++ b/packages/node-sdk/test/session-event-types.test.ts @@ -35,6 +35,11 @@ describe('Event public types', () => { expectTypeOf['runInBackground']>().toEqualTypeOf(); }); + it('narrows cron fired events by type', () => { + expectTypeOf['prompt']>().toEqualTypeOf(); + expectTypeOf['origin']['kind']>().toEqualTypeOf<'cron_job'>(); + }); + it('exposes approval and question reverse-RPC requests', () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); @@ -78,6 +83,7 @@ describe('Event public types', () => { case 'background.task.started': case 'background.task.updated': case 'background.task.terminated': + case 'cron.fired': return; default: assertNever(event);