From 2374bc41c35adc1d2e2b5116559946c8de1b98a8 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 6 Jul 2026 22:25:19 +0800 Subject: [PATCH] feat(kimi-web): render cron fire notices in the web chat (#1426) * feat(kimi-web): render cron fire notices as in-transcript cards Show scheduled-reminder fires as distinct notice cards in the web chat, both live and after a reload, instead of hiding them. Each card carries a humanized schedule, a dimmed job id, and a collapsible prompt body. * fix(kimi-web): isolate cron notice prompt id and route prefixed events - Give synthesized cron messages a fresh promptId so a fire mid-turn cannot be reconciled into the optimistic user echo and hide it. - Add cron.fired to KNOWN_AGENT_CORE_TYPES so event.-prefixed frames reach the projector and render live too. * fix(kimi-web): truncate long one-line cron prompts when collapsed Slice the first line to the collapse limit with an ellipsis when a single-line prompt exceeds it, so the collapsed state actually truncates and the expand toggle is not a no-op for common one-line reminders. * fix(kimi-web): keep cron notices from reconciling into optimistic user echoes Skip optimistic-echo reconciliation for user messages whose origin is cron_job or cron_missed: their prompt text can coincide with a still- optimistic user message, and the loose content match would otherwise replace the user's turn with the cron notice instead of appending. * fix(kimi-web): keep in-turn cron injections from breaking tool results A cron injection steered into an active turn lands inside that turn's message sequence, between a tool use and its result. Treating it as a hard user-turn boundary flushed the pending assistant group, so the next tool result had no group to fold into and the tool rendered without output. Embed such in-turn cron notices as a block inside the assistant group instead, and only render a cron at a turn boundary as its own turn. * fix(kimi-web): only embed cron notices while a tool is in flight Embedding whenever a group was pending was too broad: on REST snapshots without prompt ids the whole transcript shares one group, so an idle cron fire merged into the previous assistant answer and its own reply kept going in that group. Embed only while the group has a running tool (a cron sandwiched between a tool use and its result); flush to its own turn otherwise. * fix(kimi-web): omit synthetic prompt id on cron notices The synthesized cron message carried a cron_pr_ promptId that the web client caches into promptIdBySession for Stop/abort. Because it is not a real daemon prompt id, it clobbered the active promptId, so Stop first aborted a nonexistent prompt and only recovered via the error fallback. Omit the promptId; the reducer already skips optimistic-echo reconciliation for cron-origin messages, so it is not needed for de-dup. --- .changeset/web-cron-fire-notices.md | 5 + .../src/api/daemon/agentEventProjector.ts | 37 +++- apps/kimi-web/src/api/daemon/eventReducer.ts | 12 +- .../kimi-web/src/components/chat/ChatPane.vue | 10 +- .../src/components/chat/CronNotice.vue | 165 +++++++++++++++++ .../src/components/chatTurnRendering.ts | 7 +- .../src/composables/messagesToTurns.ts | 105 ++++++++++- .../src/i18n/locales/en/conversation.ts | 17 ++ .../src/i18n/locales/zh/conversation.ts | 17 ++ apps/kimi-web/src/lib/cronHumanize.ts | 67 +++++++ apps/kimi-web/src/types.ts | 21 ++- .../test/agent-event-projector.test.ts | 76 +++++++- apps/kimi-web/test/event-reducer.test.ts | 46 +++++ apps/kimi-web/test/lib-logic.test.ts | 59 +++++++ apps/kimi-web/test/turn-logic.test.ts | 166 ++++++++++++++++++ 15 files changed, 801 insertions(+), 9 deletions(-) create mode 100644 .changeset/web-cron-fire-notices.md create mode 100644 apps/kimi-web/src/components/chat/CronNotice.vue create mode 100644 apps/kimi-web/src/lib/cronHumanize.ts 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 }):