feat(web): redesign cron reminder as a message bubble (#1480)

* feat(web): redesign cron reminder as a message bubble

Restyle the cron trigger notice as a right-aligned user-style message bubble that shows the scheduled prompt in full (wrapping across lines), with a small meta row beneath it for the schedule, status, job id and run time. Extract a shared MessageTime component used by both user messages and the cron reminder so the timestamp format and click-to-expand behavior stay consistent, and give the CronCreate/CronList/CronDelete tools distinct calendar icons.

* refactor(web): render cron reminders only as standalone turns

Remove the embedded cron block path from the web transcript projector so cron reminder fires always render through the standalone right-aligned bubble path.

* chore(web): simplify cron redesign changeset
This commit is contained in:
liruifengv 2026-07-08 13:42:22 +08:00 committed by GitHub
parent b30a45efec
commit 2ad0120c2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 182 additions and 225 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Redesign the scheduled reminder UI.

View file

@ -9,13 +9,13 @@ import Markdown from './Markdown.vue';
import ThinkingBlock from './ThinkingBlock.vue';
import ActivityNotice from './ActivityNotice.vue';
import CronNotice from './CronNotice.vue';
import MessageTime from './MessageTime.vue';
import AuthMedia from './AuthMedia.vue';
import MoonSpinner from '../ui/MoonSpinner.vue';
import Spinner from '../ui/Spinner.vue';
import Icon from '../ui/Icon.vue';
import Tooltip from '../ui/Tooltip.vue';
import { useConfirmDialog } from '../../composables/useConfirmDialog';
import { formatMessageTime } from '../../lib/formatMessageTime';
import { copyTextToClipboard } from '../../lib/clipboard';
import {
assistantRenderBlocks,
@ -318,27 +318,6 @@ const undoingTurnId = ref<string | null>(null);
let undoFallbackTimer: ReturnType<typeof setTimeout> | null = null;
const UNDO_FALLBACK_MS = 2500;
// Expanded timestamp state (keyed by turn id)
const expandedTimeTurnIds = ref<Set<string>>(new Set());
function isTimeExpanded(turnId: string): boolean {
return expandedTimeTurnIds.value.has(turnId);
}
function toggleTime(turnId: string): void {
const next = new Set(expandedTimeTurnIds.value);
if (next.has(turnId)) next.delete(turnId);
else next.add(turnId);
expandedTimeTurnIds.value = next;
}
function displayMessageTime(iso: string, turnId: string): string {
if (isTimeExpanded(turnId)) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad2 = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
return formatMessageTime(iso, t('conversation.yesterday'));
}
async function onUndo(turn: ChatTurn): Promise<void> {
if (
await confirm({
@ -611,14 +590,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
<Icon v-if="copiedTurn !== turn.id" name="copy" size="sm" />
<Icon v-else name="check" size="sm" />
</button>
<button
v-if="turn.createdAt"
type="button"
class="u-time"
@click.stop="toggleTime(turn.id)"
>
{{ displayMessageTime(turn.createdAt, turn.id) }}
</button>
<MessageTime v-if="turn.createdAt" :time="turn.createdAt" />
</div>
</div>
</template>
@ -642,7 +614,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
<!-- Cron notice a turn triggered by a scheduled reminder, rendered as
a lightweight in-transcript notice rather than a user bubble. -->
<CronNotice v-else-if="turn.role === 'cron'" :text="turn.text" :cron="turn.cron" :turn-id="turn.id" />
<CronNotice v-else-if="turn.role === 'cron'" :text="turn.text" :cron="turn.cron" :turn-id="turn.id" :created-at="turn.createdAt" />
<!-- Assistant turn left-aligned, no name/role label. -->
<div v-else class="a-msg turn-anchor" :data-turn-id="turn.id">
@ -660,7 +632,6 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
@open-agent="emit('openAgent', $event)"
/>
<ToolCall v-else-if="blk.kind === 'tool'" :tool="blk.tool" mobile :tool-diff-panel="toolDiffPanel" @open-media="emit('openMedia', $event)" @open-file="emit('openFile', $event)" @open-tool-diff="emit('openToolDiff', $event)" @open-agent="emit('openAgent', $event)" />
<CronNotice v-else-if="blk.kind === 'cron'" :text="blk.text" :cron="blk.cron" />
</template>
<div v-if="turn.id !== streamingTurnId && isAssistantRunEnd(ti) && (assistantRunFinalText(ti).trim().length > 0 || turn.durationMs !== undefined)" class="a-msg-ft">
<Tooltip :text="`${turn.durationMs} ms`">
@ -855,29 +826,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
margin-top: 2px;
margin-right: 4px;
}
.u-meta .u-time {
display: inline-flex;
align-items: center;
padding: 2px 5px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--muted);
font: inherit;
font-size: var(--text-base);
line-height: 1;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.12s, color 0.12s, background-color 0.12s;
white-space: nowrap;
}
.u-meta .u-time:hover {
opacity: 1;
color: var(--color-accent);
background: var(--hover);
}
.u-meta .u-edit,
.u-meta .u-time {
.u-meta .u-edit {
min-height: 22px;
box-sizing: border-box;
}

View file

@ -1,19 +1,23 @@
<!-- apps/kimi-web/src/components/chat/CronNotice.vue -->
<!-- In-transcript card for a turn triggered by a scheduled reminder rather
than a real user. A left accent bar + soft tint make it read as one event
card distinct from the chat around it; the header carries the glyph, title,
a humanized schedule ("Every 5 minutes") and a dimmed job id; the fired
prompt is collapsed to its first line with an expand toggle so long prompts
don't swamp the transcript. stale/missed fires switch the accent to warning.
<!-- In-transcript notice for a turn triggered by a scheduled reminder rather
than a real user. It is styled to read like a user message a right-
aligned, max-width-capped bubble in the user-bubble colour because a cron
fire is semantically a message the user scheduled earlier. The bubble shows
the title + the fired prompt in full, wrapping across lines (no truncation,
no tooltip). Schedule / status / job id / fire time sit in a small meta row
beneath the bubble, mirroring the meta row under a real user message; the
fire time reuses the same <MessageTime> component as a user message so the
two stay identical.
Renders either as a standalone turn (pass turnId for the scroll anchor) or
embedded inside an assistant turn's blocks (a cron steered into an in-flight
turn) in both cases it takes the same text + cron data. -->
embedded inside an assistant turn's blocks in both cases it takes the
same text + cron data. -->
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from '../ui/Icon.vue';
import { humanizeCron, collapsePrompt } from '../../lib/cronHumanize';
import MessageTime from './MessageTime.vue';
import { humanizeCron } from '../../lib/cronHumanize';
import type { CronTurnData } from '../../types';
const props = defineProps<{
@ -22,13 +26,15 @@ const props = defineProps<{
/** Scroll-anchor id for a standalone cron turn; omitted when embedded in an
* assistant turn's blocks (the assistant turn already carries the anchor). */
turnId?: string;
/** ISO timestamp of when the cron fired (the turn's createdAt). Omitted for
* the embedded-in-assistant case, which has no turn of its own. */
createdAt?: string;
}>();
const { t } = useI18n();
const cron = computed(() => props.cron);
const missed = computed(() => cron.value?.missedCount !== undefined);
const warning = computed(() => cron.value?.stale === true || missed.value);
const title = computed(() =>
missed.value ? t('conversation.cron.missed') : t('conversation.cron.fired'),
@ -39,9 +45,12 @@ const schedule = computed(() => {
return expr ? humanizeCron(expr, t) : '';
});
// Status-only metadata: schedule + job id already live in the header, so this
// line surfaces only the fire-state flags (one-shot / coalesced / missed /
// final delivery) and is hidden when none apply.
// A clean fire reads as "ok" (green ); a missed fire (skipped runs) as
// "error" (red ). Surfaced in the meta row below the bubble.
const statusKind = computed<'ok' | 'error'>(() => (missed.value ? 'error' : 'ok'));
// Fire-state flags (one-shot / coalesced / missed / final delivery); shown in
// the meta row when any apply.
const statusDetail = computed(() => {
const c = cron.value;
if (!c) return '';
@ -57,109 +66,95 @@ const statusDetail = computed(() => {
return parts.join(' · ');
});
const expanded = ref(false);
const text = computed(() => props.text ?? '');
const collapsed = computed(() => collapsePrompt(text.value));
const hasMore = computed(() => collapsed.value.hasMore);
const shownText = computed(() => (expanded.value ? text.value : collapsed.value.text));
</script>
<template>
<div
class="cron-notice"
:class="{ 'is-warning': warning, 'turn-anchor': !!turnId }"
class="cn cron-notice"
:class="{ 'turn-anchor': !!turnId }"
:data-turn-id="turnId"
role="status"
>
<div class="cn-head">
<span class="cn-icon" aria-hidden="true"><Icon name="clock" size="sm" /></span>
<div class="cn-bubble">
<span class="cn-title">{{ title }}</span>
<span v-if="schedule" class="cn-badge">{{ schedule }}</span>
<template v-if="text"> <span class="cn-prompt">{{ text }}</span></template>
</div>
<div class="cn-meta">
<Icon name="clock" size="sm" class="cn-meta-ico" aria-hidden="true" />
<span v-if="schedule" class="cn-meta-item">{{ schedule }}</span>
<span v-if="statusDetail" class="cn-meta-item">{{ statusDetail }}</span>
<span class="cn-status" :class="statusKind" :aria-label="statusKind">
<Icon v-if="statusKind === 'ok'" name="check" size="sm" />
<Icon v-else name="close" size="sm" />
</span>
<span
v-if="cron?.jobId"
class="cn-job"
class="cn-meta-item cn-id"
:title="t('conversation.cron.job', { id: cron.jobId })"
>{{ cron.jobId }}</span>
<MessageTime v-if="createdAt" :time="createdAt" />
</div>
<div v-if="statusDetail" class="cn-status">{{ statusDetail }}</div>
<div v-if="text" class="cn-prompt">{{ shownText }}</div>
<button
v-if="text && hasMore"
type="button"
class="cn-toggle"
@click="expanded = !expanded"
>{{ expanded ? t('conversation.cron.collapse') : t('conversation.cron.expand') }}</button>
</div>
</template>
<style scoped>
.cron-notice {
display: block;
align-self: stretch;
border-left: 3px solid var(--color-accent);
background: var(--color-accent-soft);
border-radius: 8px;
padding: 8px 12px;
font: var(--text-sm)/var(--leading-normal) var(--font-ui);
color: var(--color-text);
}
.cron-notice.is-warning {
border-left-color: var(--color-warning);
background: var(--color-warning-soft);
}
.cn-head {
.cn {
margin: 0;
align-self: flex-end;
max-width: 78%;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
flex-direction: column;
align-items: flex-end;
}
.cn-icon {
display: inline-flex;
flex: none;
color: var(--color-accent);
}
.cron-notice.is-warning .cn-icon {
color: var(--color-warning);
/* Mirrors the user bubble (.u-bub): accent fill + border, rounded with one
small corner, soft shadow. The prompt is shown in full and wraps across
lines (long tokens break) no truncation. */
.cn-bubble {
box-sizing: border-box;
max-width: 100%;
padding: 8px 14px;
background: var(--color-accent-soft);
border: 1px solid var(--color-accent-bd);
border-radius: var(--radius-xl) var(--radius-xl) var(--radius-sm) var(--radius-xl);
box-shadow: var(--shadow-xs);
color: var(--color-text);
font-size: var(--content-font-size);
line-height: var(--leading-normal);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.cn-title {
font-weight: 600;
font-weight: var(--weight-medium);
}
.cn-badge {
font: var(--text-xs)/var(--leading-normal) var(--font-ui);
color: var(--color-text-muted);
border: 1px solid var(--color-accent-bd);
border-radius: 999px;
padding: 0 8px;
/* Meta row under the bubble, sized to match the user message's meta row. */
.cn-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
padding: 0 4px;
color: var(--color-text-faint);
font-size: var(--text-base);
line-height: var(--leading-normal);
}
.cn-job {
margin-left: auto;
font: var(--text-xs)/var(--leading-normal) var(--font-ui);
color: var(--color-text-muted);
opacity: 0.7;
.cn-meta-ico {
flex: none;
color: var(--color-text-faint);
}
.cn-meta-item {
white-space: nowrap;
}
.cn-status {
margin-top: 4px;
font: var(--text-xs)/var(--leading-normal) var(--font-ui);
color: var(--color-text-muted);
display: inline-flex;
align-items: center;
}
.cn-prompt {
margin-top: 6px;
color: var(--color-text);
white-space: pre-wrap;
word-break: break-word;
.cn-status.ok {
color: var(--color-success);
}
.cn-toggle {
margin-top: 4px;
padding: 0;
border: 0;
background: none;
font: var(--text-xs)/var(--leading-normal) var(--font-ui);
color: var(--color-accent);
cursor: pointer;
}
.cn-toggle:hover {
text-decoration: underline;
.cn-status.error {
color: var(--color-danger);
}
</style>

View file

@ -0,0 +1,62 @@
<!-- apps/kimi-web/src/components/chat/MessageTime.vue -->
<!-- Click-to-expand timestamp shown under a message bubble (a real user
message or a cron-fired message). Collapsed: a compact form via
formatMessageTime (today "HH:MM", yesterday "昨天 HH:MM", this year
"MM-DD HH:MM", older "YYYY-MM-DD HH:MM"). Expanded on click: the full
"YYYY-MM-DD HH:MM". One component so the time under a user message and a
cron notice stays identical. -->
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { formatMessageTime } from '../../lib/formatMessageTime';
const props = defineProps<{ time: string }>();
const { t } = useI18n();
const expanded = ref(false);
const full = computed(() => {
const d = new Date(props.time);
if (Number.isNaN(d.getTime())) return props.time;
const pad2 = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
});
const display = computed(() =>
expanded.value ? full.value : formatMessageTime(props.time, t('conversation.yesterday')),
);
function toggle(): void {
expanded.value = !expanded.value;
}
</script>
<template>
<button type="button" class="msg-time" @click.stop="toggle">{{ display }}</button>
</template>
<style scoped>
.msg-time {
display: inline-flex;
align-items: center;
min-height: 22px;
box-sizing: border-box;
padding: 2px 5px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--muted);
font: inherit;
font-size: var(--text-base);
line-height: 1;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.12s, color 0.12s, background-color 0.12s;
white-space: nowrap;
}
.msg-time:hover {
opacity: 1;
color: var(--color-accent);
background: var(--hover);
}
</style>

View file

@ -2,7 +2,7 @@
// Pure turn-rendering helpers: pure functions of their arguments (no Vue
// reactivity, no component state). Shared by ChatPane.vue's template and its
// stateful copy/edit helpers.
import type { ChatTurn, CronTurnData, TurnBlock } from '../types';
import type { ChatTurn, TurnBlock } from '../types';
export function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
@ -41,8 +41,7 @@ export type AssistantRenderBlock =
| { kind: 'thinking'; thinking: string; sourceIndex: number }
| { kind: 'text'; text: string; sourceIndex: number }
| { kind: 'tool'; tool: ToolStackItem['tool']; sourceIndex: number }
| { kind: 'tool-stack'; tools: ToolStackItem[] }
| { kind: 'cron'; text: string; cron: CronTurnData; sourceIndex: number };
| { kind: 'tool-stack'; tools: ToolStackItem[] };
export function rendersToolCard(block: Extract<TurnBlock, { kind: 'tool' }>): boolean {
return !(block.tool.status === 'ok' && block.tool.media);
@ -86,8 +85,6 @@ export function assistantRenderBlocks(turn: ChatTurn): AssistantRenderBlock[] {
rendered.push({ kind: 'thinking', thinking: block.thinking, sourceIndex });
} else if (block.kind === 'text') {
rendered.push({ kind: 'text', text: block.text, sourceIndex });
} else if (block.kind === 'cron') {
rendered.push({ kind: 'cron', text: block.text, cron: block.cron, sourceIndex });
}
});

View file

@ -415,10 +415,6 @@ function buildCronTurn(msg: AppMessage, no: number, kind: 'cron_job' | 'cron_mis
return { id: msg.id, role: 'cron', no, text, createdAt: msg.createdAt, cron };
}
function buildCronBlock(msg: AppMessage, kind: 'cron_job' | 'cron_missed'): TurnBlock {
const { text, cron } = buildCronData(msg, kind);
return { kind: 'cron', text, cron };
}
/**
* Whether a USER-role message should be shown. Mirrors the TUI's
@ -457,12 +453,6 @@ function continuesAssistantGroup(group: Group | null, promptId: string | undefin
);
}
/** True while a tool in this group has been called but not yet resolved i.e.
* the group is mid-tool-call. A cron injection that arrives now is sandwiched
* between the tool use and its result and must not flush the group. */
function hasRunningTool(group: Group): boolean {
return group.tools.some((t) => t.status === 'running');
}
/** Extract the plan file path from an ExitPlanMode tool result. The approved
* output contains `Plan saved to: <path>`; this survives a page reload (unlike
@ -653,19 +643,10 @@ export function messagesToTurns(
// User messages flush the pending group and start a new user turn
if (msg.role === 'user') {
const cronKind = cronOriginKind(msg);
// A cron injection steered into an in-flight turn can land between a
// tool use and its result in the live event stream. It must NOT flush the
// pending assistant group then — flushing would orphan the next
// tool.result, which only folds into a pending group, leaving the tool
// rendered without output. So embed it as a block only while the group
// has an in-flight tool. Otherwise — a cron at a turn boundary, including
// an idle fire on a REST snapshot that carries no prompt ids (where the
// whole transcript shares one group) — flush and render it as its own
// turn so it doesn't merge into the previous answer.
if (cronKind !== undefined && pendingGroup !== null && hasRunningTool(pendingGroup)) {
pendingGroup.blocks.push(buildCronBlock(msg, cronKind));
continue;
}
// A cron injection always renders as its own standalone turn: agent-core
// buffers steer input while a turn is in flight and only injects it at the
// turn boundary, so the cron message does not land between a tool use and
// its result in practice.
flushGroup();
if (cronKind !== undefined) {
turns.push(buildCronTurn(msg, no++, cronKind));

View file

@ -26,6 +26,9 @@ import RiArrowRightLine from '~icons/ri/arrow-right-line';
import RiArrowRightSLine from '~icons/ri/arrow-right-s-line';
import RiArrowUpLine from '~icons/ri/arrow-up-line';
import RiBracesLine from '~icons/ri/braces-line';
import RiCalendarCloseLine from '~icons/ri/calendar-close-line';
import RiCalendarScheduleLine from '~icons/ri/calendar-schedule-line';
import RiCalendarTodoLine from '~icons/ri/calendar-todo-line';
import RiChatNewLine from '~icons/ri/chat-new-line';
import RiCheckLine from '~icons/ri/check-line';
import RiCloseLine from '~icons/ri/close-line';
@ -84,6 +87,9 @@ import RawArrowRightLine from '~icons/ri/arrow-right-line?raw';
import RawArrowRightSLine from '~icons/ri/arrow-right-s-line?raw';
import RawArrowUpLine from '~icons/ri/arrow-up-line?raw';
import RawBracesLine from '~icons/ri/braces-line?raw';
import RawCalendarCloseLine from '~icons/ri/calendar-close-line?raw';
import RawCalendarScheduleLine from '~icons/ri/calendar-schedule-line?raw';
import RawCalendarTodoLine from '~icons/ri/calendar-todo-line?raw';
import RawChatNewLine from '~icons/ri/chat-new-line?raw';
import RawCheckLine from '~icons/ri/check-line?raw';
import RawCloseLine from '~icons/ri/close-line?raw';
@ -136,6 +142,9 @@ import RawUserLine from '~icons/ri/user-line?raw';
export type IconName =
| 'plus'
| 'chat-new'
| 'calendar-close'
| 'calendar-schedule'
| 'calendar-todo'
| 'close'
| 'check'
| 'search'
@ -212,6 +221,9 @@ function entry(component: Component, svg: string): IconEntry {
export const ICONS: Record<IconName, IconEntry> = {
plus: entry(RiAddLine, RawAddLine),
'chat-new': entry(RiChatNewLine, RawChatNewLine),
'calendar-close': entry(RiCalendarCloseLine, RawCalendarCloseLine),
'calendar-schedule': entry(RiCalendarScheduleLine, RawCalendarScheduleLine),
'calendar-todo': entry(RiCalendarTodoLine, RawCalendarTodoLine),
close: entry(RiCloseLine, RawCloseLine),
check: entry(RiCheckLine, RawCheckLine),
search: entry(RiSearchLine, RawSearchLine),
@ -353,6 +365,9 @@ export const ICON_GROUPS: ReadonlyArray<readonly [string, readonly IconName[]]>
'check-list',
'bolt',
'git-pull-request',
'calendar-schedule',
'calendar-todo',
'calendar-close',
],
],
['Communication', ['message', 'mail', 'user']],

View file

@ -93,6 +93,10 @@ const TOOL_GLYPH: Record<string, IconName> = {
task: 'sparkles',
agentswarm: 'git-pull-request',
askuserquestion: 'help-circle',
// Cron scheduling tools share a calendar motif: schedule / list / cancel.
croncreate: 'calendar-schedule',
cronlist: 'calendar-todo',
crondelete: 'calendar-close',
};
export function toolGlyph(name: string): string {

View file

@ -229,8 +229,7 @@ export interface CronTurnData {
export type TurnBlock =
| { kind: 'text'; text: string }
| { kind: 'thinking'; thinking: string }
| { kind: 'tool'; tool: ToolCall }
| { kind: 'cron'; text: string; cron: CronTurnData };
| { kind: 'tool'; tool: ToolCall };
export interface ChatTurn {
id: string;

View file

@ -320,56 +320,6 @@ describe('messagesToTurns cron', () => {
expect(turns).toHaveLength(1);
});
it('embeds an in-turn cron injection as a block and keeps folding the following tool result', () => {
const envelope =
'<cron-fire jobId="j" cron="* * * * *" recurring="true" coalescedCount="1" stale="false">\n' +
'<prompt>\nCheck BTC\n</prompt>\n</cron-fire>';
const turns = messagesToTurns(
[
message('u1', 'user', [{ type: 'text', text: 'hi' }]),
message('a1', 'assistant', [
{
type: 'toolUse',
toolCallId: 'tc1',
toolName: 'FetchURL',
input: { url: 'https://example.com' },
},
]),
message('c1', 'user', [{ type: 'text', text: envelope }], {
metadata: {
origin: {
kind: 'cron_job',
jobId: 'j',
cron: '* * * * *',
recurring: true,
coalescedCount: 1,
stale: false,
},
},
}),
message('t1', 'tool', [
{ type: 'toolResult', toolCallId: 'tc1', output: 'the price is 62k' },
]),
],
[],
);
// user turn + one assistant turn (tool + embedded cron block + result).
// No standalone cron turn, and the tool result is preserved.
expect(turns).toHaveLength(2);
const assistant = turns[1]!;
expect(assistant.role).toBe('assistant');
expect(assistant.tools?.[0]).toMatchObject({
id: 'tc1',
status: 'ok',
output: ['the price is 62k'],
});
expect(assistant.blocks?.find((b) => b.kind === 'cron')).toMatchObject({
kind: 'cron',
text: 'Check BTC',
cron: { jobId: 'j' },
});
});
it('flushes an idle cron fire as its own turn even when no prompt ids are present', () => {
const envelope =