diff --git a/.changeset/web-cron-fire-notices.md b/.changeset/web-cron-fire-notices.md new file mode 100644 index 000000000..22605bf24 --- /dev/null +++ b/.changeset/web-cron-fire-notices.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Show scheduled-reminder (cron) fires as notice cards in the chat instead of hiding them. diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index 9561aa713..a63d781cf 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -1217,10 +1217,44 @@ export function createAgentProjector(): AgentProjector { break; } + // ----------------------------------------------------------------------- + case 'cron.fired': { + // A scheduled reminder fired into the session. agent-core persists the + // injected user message (so a refresh renders it via messagesToTurns), + // but turn.steer() does NOT broadcast a prompt.submitted / message.created + // for it — synthesize one here so the notice shows up live too. A later + // snapshot reload replaces the message log wholesale, so this synthesized + // copy never duplicates the persisted one. The promptId is intentionally + // omitted: the web client caches every user message's promptId into + // promptIdBySession for Stop/abort, and a synthetic id the daemon would + // reject would clobber the real active promptId. The reducer already skips + // optimistic-echo reconciliation for cron-origin messages, so no promptId + // is needed for de-dup either. + const origin = p?.origin; + const promptText = stringField(p ?? {}, 'prompt'); + if ( + origin && + typeof origin === 'object' && + (origin as Record)['kind'] === 'cron_job' && + promptText + ) { + const msg: AppMessage = { + id: ulid('cron_'), + sessionId, + role: 'user', + content: [{ type: 'text', text: promptText }], + createdAt: new Date().toISOString(), + metadata: { origin: origin as Record }, + }; + s.messages.push(msg); + out.push({ type: 'messageCreated', message: cloneMessage(msg) }); + } + break; + } + // ----------------------------------------------------------------------- // Explicitly known but not projected case 'compaction.blocked': - case 'cron.fired': case 'hook.result': case 'mcp.server.status': case 'skill.activated': @@ -1301,6 +1335,7 @@ const KNOWN_AGENT_CORE_TYPES = new Set([ 'subagent.failed', 'background.task.started', 'background.task.terminated', + 'cron.fired', ]); /** diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index 69b400038..970fbc925 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -119,6 +119,11 @@ function isOptimisticUserMessage(message: AppMessage): boolean { ); } +function isCronOriginMessage(message: AppMessage): boolean { + const origin = message.metadata?.['origin'] as { kind?: string } | undefined; + return origin?.kind === 'cron_job' || origin?.kind === 'cron_missed'; +} + function sameMessageContent(a: AppMessage, b: AppMessage): boolean { return JSON.stringify(a.content) === JSON.stringify(b.content); } @@ -388,7 +393,12 @@ export function reduceAppEvent( const msgs = next.messagesBySession[sid] ?? []; const exists = msgs.some((m) => m.id === event.message.id); if (!exists) { - if (event.message.role === 'user') { + // Cron-injected user messages (origin cron_job/cron_missed) carry the + // reminder's prompt as their text, which can coincide with a still- + // optimistic user message. They must append as their own turn rather + // than reconcile into (and replace) that optimistic echo — so skip the + // echo lookup entirely for them. + if (event.message.role === 'user' && !isCronOriginMessage(event.message)) { const optimisticIndex = findOptimisticUserEchoIndex(msgs, event.message); if (optimisticIndex !== -1) { const updated = [...msgs]; diff --git a/apps/kimi-web/src/components/chat/ChatPane.vue b/apps/kimi-web/src/components/chat/ChatPane.vue index 5ea1f1990..7103a8cb8 100644 --- a/apps/kimi-web/src/components/chat/ChatPane.vue +++ b/apps/kimi-web/src/components/chat/ChatPane.vue @@ -8,6 +8,7 @@ import ToolGroup from './ToolGroup.vue'; import Markdown from './Markdown.vue'; import ThinkingBlock from './ThinkingBlock.vue'; import ActivityNotice from './ActivityNotice.vue'; +import CronNotice from './CronNotice.vue'; import AuthMedia from './AuthMedia.vue'; import MoonSpinner from '../ui/MoonSpinner.vue'; import Spinner from '../ui/Spinner.vue'; @@ -387,7 +388,7 @@ function copyConversation(): void { if (props.turns.length === 0) return; const lines: string[] = []; for (const turn of props.turns) { - if (turn.role === 'compaction') continue; // dividers don't copy + if (turn.role === 'compaction' || turn.role === 'cron') continue; // dividers / cron notices don't copy const roleLabel = turn.role === 'user' ? 'User' : 'Assistant'; const content = turnToMarkdown(turn); if (content.trim()) { @@ -639,6 +640,10 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):