Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/visible-cron-reminders.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions apps/kimi-code/src/cli/run-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand All @@ -403,6 +404,7 @@ function runPromptTurn(
case 'tool.list.updated':
case 'turn.started':
case 'turn.step.completed':
case 'warning':
return;
}
});
Expand Down
71 changes: 71 additions & 0 deletions apps/kimi-code/src/tui/components/messages/cron-message.ts
Original file line number Diff line number Diff line change
@@ -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;
}
22 changes: 22 additions & 0 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
CompactionCancelledEvent,
CompactionCompletedEvent,
CompactionStartedEvent,
CronFiredEvent,
ErrorEvent,
Event,
HookResultEvent,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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();
Expand Down
64 changes: 60 additions & 4 deletions apps/kimi-code/src/tui/controllers/session-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cron-fire ...> 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;
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -471,3 +504,26 @@ export class SessionReplayRenderer {
sessionEventHandler.backgroundAgentMetadata.delete(meta.agentId);
}
}

function extractCronPrompt(text: string): string {
const open = '<prompt>\n';
const close = '\n</prompt>';
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('<cron-fire ') &&
lines.at(-1) === '</cron-fire>'
) {
return lines.slice(1, -1).join('\n');
}
return text;
}
7 changes: 7 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 12 additions & 1 deletion apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,24 @@ 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'
| 'assistant'
| 'tool_call'
| 'thinking'
| 'status'
| 'skill_activation';
| 'skill_activation'
| 'cron';

export interface TranscriptEntry {
id: string;
Expand All @@ -115,6 +125,7 @@ export interface TranscriptEntry {
toolCallData?: ToolCallBlockData;
backgroundAgentStatus?: BackgroundAgentStatusData;
compactionData?: CompactionTranscriptData;
cronData?: CronTranscriptData;
imageAttachmentIds?: readonly number[];
skillActivationId?: string;
skillName?: string;
Expand Down
40 changes: 40 additions & 0 deletions apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<cron-fire');
});

it('coalesces assistant delta component updates', async () => {
vi.useFakeTimers();
try {
Expand Down
21 changes: 17 additions & 4 deletions apps/kimi-code/test/tui/message-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
'<cron-fire jobId="job-1" cron="*/5 * * * *" recurring="true" coalescedCount="1">\nrun nightly\n</cron-fire>';
'<cron-fire jobId="job-1" cron="*/5 * * * *" recurring="true" coalescedCount="1" stale="false">\n<prompt>\nrun nightly\n</prompt>\n</cron-fire>';
const driver = await replayIntoDriver([
message('user', [{ type: 'text', text: 'real prompt' }]),
message('assistant', [{ type: 'text', text: 'real answer' }]),
Expand All @@ -392,14 +392,21 @@ describe('KimiTUI resume message replay', () => {

const transcript = driver.state.transcriptContainer.render(120).join('\n');
expect(transcript).not.toContain('<cron-fire');
expect(transcript).toContain('Scheduled reminder fired');
expect(transcript).toContain('run nightly');
expect(
driver.state.transcriptEntries
.filter((entry) => 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 =
'<cron-fire jobId="job-2" missed="true" count="3">\n3 one-shot tasks missed while offline\n</cron-fire>';
const driver = await replayIntoDriver([
Expand All @@ -412,12 +419,18 @@ describe('KimiTUI resume message replay', () => {

const transcript = driver.state.transcriptContainer.render(120).join('\n');
expect(transcript).not.toContain('<cron-fire');
expect(transcript).not.toContain('missed while offline');
expect(transcript).toContain('Missed scheduled reminders');
expect(transcript).toContain('3 one-shot tasks missed while offline');
expect(
driver.state.transcriptEntries
.filter((entry) => 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 () => {
Expand Down
Loading
Loading