mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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.
This commit is contained in:
parent
ac5b5e4cbf
commit
2374bc41c3
15 changed files with 801 additions and 9 deletions
5
.changeset/web-cron-fire-notices.md
Normal file
5
.changeset/web-cron-fire-notices.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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<string, unknown>)['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<string, unknown> },
|
||||
};
|
||||
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',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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 }):
|
|||
<span class="cd-line" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<!-- 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" />
|
||||
|
||||
<!-- Assistant turn → left-aligned, no name/role label. -->
|
||||
<div v-else class="a-msg turn-anchor" :data-turn-id="turn.id">
|
||||
<template v-for="(blk, bi) in assistantRenderBlocks(turn)" :key="renderBlockKey(blk, bi)">
|
||||
|
|
@ -655,6 +660,7 @@ 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`">
|
||||
|
|
@ -801,6 +807,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
|
|||
.chat > .u-turn,
|
||||
.chat > .a-msg,
|
||||
.chat > .compact-divider,
|
||||
.chat > .cron-notice,
|
||||
.chat > .sending-placeholder,
|
||||
.chat > :deep(.activity-notice) {
|
||||
margin-top: var(--chat-turn-gap);
|
||||
|
|
@ -811,6 +818,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
|
|||
.chat > .u-turn:first-child,
|
||||
.chat > .a-msg:first-child,
|
||||
.chat > .compact-divider:first-child,
|
||||
.chat > .cron-notice:first-child,
|
||||
.chat > .sending-placeholder:first-child,
|
||||
.chat > :deep(.activity-notice:first-child) {
|
||||
margin-top: 0;
|
||||
|
|
|
|||
165
apps/kimi-web/src/components/chat/CronNotice.vue
Normal file
165
apps/kimi-web/src/components/chat/CronNotice.vue
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<!-- 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.
|
||||
|
||||
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. -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from '../ui/Icon.vue';
|
||||
import { humanizeCron, collapsePrompt } from '../../lib/cronHumanize';
|
||||
import type { CronTurnData } from '../../types';
|
||||
|
||||
const props = defineProps<{
|
||||
text: string;
|
||||
cron?: CronTurnData;
|
||||
/** 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;
|
||||
}>();
|
||||
|
||||
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'),
|
||||
);
|
||||
|
||||
const schedule = computed(() => {
|
||||
const expr = cron.value?.cron;
|
||||
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.
|
||||
const statusDetail = computed(() => {
|
||||
const c = cron.value;
|
||||
if (!c) return '';
|
||||
const parts: string[] = [];
|
||||
if (c.recurring === false) parts.push(t('conversation.cron.oneShot'));
|
||||
if (typeof c.coalescedCount === 'number' && c.coalescedCount > 1) {
|
||||
parts.push(t('conversation.cron.coalesced', { n: c.coalescedCount }));
|
||||
}
|
||||
if (c.missedCount !== undefined) {
|
||||
parts.push(t('conversation.cron.missedCount', { n: c.missedCount }));
|
||||
}
|
||||
if (c.stale === true) parts.push(t('conversation.cron.finalDelivery'));
|
||||
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 }"
|
||||
:data-turn-id="turnId"
|
||||
role="status"
|
||||
>
|
||||
<div class="cn-head">
|
||||
<span class="cn-icon" aria-hidden="true"><Icon name="clock" size="sm" /></span>
|
||||
<span class="cn-title">{{ title }}</span>
|
||||
<span v-if="schedule" class="cn-badge">{{ schedule }}</span>
|
||||
<span
|
||||
v-if="cron?.jobId"
|
||||
class="cn-job"
|
||||
:title="t('conversation.cron.job', { id: cron.jobId })"
|
||||
>{{ cron.jobId }}</span>
|
||||
</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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cn-icon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.cron-notice.is-warning .cn-icon {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
.cn-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.cn-job {
|
||||
margin-left: auto;
|
||||
font: var(--text-xs)/var(--leading-normal) var(--font-ui);
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.cn-status {
|
||||
margin-top: 4px;
|
||||
font: var(--text-xs)/var(--leading-normal) var(--font-ui);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.cn-prompt {
|
||||
margin-top: 6px;
|
||||
color: var(--color-text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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, TurnBlock } from '../types';
|
||||
import type { ChatTurn, CronTurnData, TurnBlock } from '../types';
|
||||
|
||||
export function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
|
|
@ -41,7 +41,8 @@ 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: 'tool-stack'; tools: ToolStackItem[] }
|
||||
| { kind: 'cron'; text: string; cron: CronTurnData; sourceIndex: number };
|
||||
|
||||
export function rendersToolCard(block: Extract<TurnBlock, { kind: 'tool' }>): boolean {
|
||||
return !(block.tool.status === 'ok' && block.tool.media);
|
||||
|
|
@ -85,6 +86,8 @@ 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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
import type { AppMessage, AppApprovalRequest, AppTask, CompactionMarkerMetadata } from '../api/types';
|
||||
import { COMPACTION_MARKER_METADATA_KEY } from '../api/types';
|
||||
import type { AgentMember, ApprovalBlock, ChatTurn, DiffLine, ToolCall, ToolMedia, TurnBlock } from '../types';
|
||||
import type { AgentMember, ApprovalBlock, ChatTurn, CronTurnData, DiffLine, ToolCall, ToolMedia, TurnBlock } from '../types';
|
||||
|
||||
const READ_MEDIA_TOOL_RE = /^read[_-]?media(?:file)?$/i;
|
||||
const DATA_URL_RE = /^data:([^;]+);base64,(.*)$/s;
|
||||
|
|
@ -342,6 +342,84 @@ interface Group {
|
|||
// messagesToTurns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pull the prompt body out of a cron-fire envelope. Server-side, a cron
|
||||
* injection reaches the transcript as a user message whose text is wrapped in
|
||||
* `<cron-fire …>\n<prompt>\n…\n</prompt>\n</cron-fire>` (see renderCronFireXml
|
||||
* in agent-core). We surface only the inner prompt, mirroring the TUI's
|
||||
* extractCronPrompt / stripCronEnvelope.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
function cronOriginKind(msg: AppMessage): 'cron_job' | 'cron_missed' | undefined {
|
||||
const origin = msg.metadata?.['origin'] as { kind?: string } | undefined;
|
||||
if (origin?.kind === 'cron_job' || origin?.kind === 'cron_missed') return origin.kind;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function cronPromptText(msg: AppMessage): string {
|
||||
const raw = msg.content
|
||||
.filter((c): c is { type: 'text'; text: string } => c.type === 'text')
|
||||
.map((c) => c.text)
|
||||
.join('\n');
|
||||
return extractCronPrompt(raw);
|
||||
}
|
||||
|
||||
function buildCronData(
|
||||
msg: AppMessage,
|
||||
kind: 'cron_job' | 'cron_missed',
|
||||
): { text: string; cron: CronTurnData } {
|
||||
const origin = (msg.metadata?.['origin'] ?? {}) as Record<string, unknown>;
|
||||
const text = cronPromptText(msg);
|
||||
if (kind === 'cron_missed') {
|
||||
return {
|
||||
text,
|
||||
cron: { missedCount: typeof origin['count'] === 'number' ? origin['count'] : undefined },
|
||||
};
|
||||
}
|
||||
return {
|
||||
text,
|
||||
cron: {
|
||||
jobId: typeof origin['jobId'] === 'string' ? origin['jobId'] : undefined,
|
||||
cron: typeof origin['cron'] === 'string' ? origin['cron'] : undefined,
|
||||
recurring: typeof origin['recurring'] === 'boolean' ? origin['recurring'] : undefined,
|
||||
coalescedCount: typeof origin['coalescedCount'] === 'number' ? origin['coalescedCount'] : undefined,
|
||||
stale: typeof origin['stale'] === 'boolean' ? origin['stale'] : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildCronTurn(msg: AppMessage, no: number, kind: 'cron_job' | 'cron_missed'): ChatTurn {
|
||||
const { text, cron } = buildCronData(msg, kind);
|
||||
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
|
||||
* isReplayUserTurnRecord: only real user input (origin `user`/absent, or a
|
||||
|
|
@ -379,6 +457,13 @@ 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
|
||||
* the ephemeral plan_review approval display), so the tool card can still link
|
||||
|
|
@ -567,7 +652,25 @@ 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;
|
||||
}
|
||||
flushGroup();
|
||||
if (cronKind !== undefined) {
|
||||
turns.push(buildCronTurn(msg, no++, cronKind));
|
||||
continue;
|
||||
}
|
||||
// Hide system-injected user turns (TUI parity) — they end the previous
|
||||
// assistant turn but aren't rendered as a user bubble.
|
||||
if (!isDisplayableUserMessage(msg)) continue;
|
||||
|
|
|
|||
|
|
@ -21,4 +21,21 @@ export default {
|
|||
yesterday: 'Yesterday',
|
||||
loadOlder: 'Load earlier messages',
|
||||
loadingOlder: 'Loading earlier messages…',
|
||||
cron: {
|
||||
fired: 'Scheduled reminder fired',
|
||||
missed: 'Missed scheduled reminders',
|
||||
job: 'job {id}',
|
||||
oneShot: 'one-shot',
|
||||
coalesced: '{n} fires coalesced',
|
||||
missedCount: '{n} missed',
|
||||
finalDelivery: 'final delivery',
|
||||
everyMinute: 'Every minute',
|
||||
everyNMinutes: 'Every {n} minutes',
|
||||
everyHour: 'Every hour',
|
||||
everyNHours: 'Every {n} hours',
|
||||
dailyAt: 'Daily at {time}',
|
||||
weekdaysAt: 'Weekdays at {time}',
|
||||
expand: 'Show more',
|
||||
collapse: 'Show less',
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -21,4 +21,21 @@ export default {
|
|||
yesterday: '昨天',
|
||||
loadOlder: '加载更早的消息',
|
||||
loadingOlder: '正在加载更早的消息…',
|
||||
cron: {
|
||||
fired: '定时任务已触发',
|
||||
missed: '错过的定时提醒',
|
||||
job: '任务 {id}',
|
||||
oneShot: '单次',
|
||||
coalesced: '已合并 {n} 次触发',
|
||||
missedCount: '错过 {n} 次',
|
||||
finalDelivery: '最后一次投递',
|
||||
everyMinute: '每分钟',
|
||||
everyNMinutes: '每 {n} 分钟',
|
||||
everyHour: '每小时',
|
||||
everyNHours: '每 {n} 小时',
|
||||
dailyAt: '每天 {time}',
|
||||
weekdaysAt: '工作日 {time}',
|
||||
expand: '展开',
|
||||
collapse: '收起',
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
67
apps/kimi-web/src/lib/cronHumanize.ts
Normal file
67
apps/kimi-web/src/lib/cronHumanize.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// apps/kimi-web/src/lib/cronHumanize.ts
|
||||
// Turn a 5-field cron expression into a short human-readable label for the
|
||||
// cron notice header (e.g. "*/5 * * * *" → "Every 5 minutes"). Falls back to
|
||||
// the raw expression for anything we don't recognize — better to show the
|
||||
// truth than a wrong friendly label.
|
||||
|
||||
type Translator = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
function pad2(n: string): string {
|
||||
return n.length === 1 ? `0${n}` : n;
|
||||
}
|
||||
|
||||
/** 9:05-style time (hour not zero-padded, minute zero-padded). */
|
||||
function clockTime(hour: string, minute: string): string {
|
||||
return `${String(Number(hour))}:${pad2(minute)}`;
|
||||
}
|
||||
|
||||
const isNum = (s: string): boolean => /^\d+$/.test(s);
|
||||
|
||||
export function humanizeCron(expr: string, t: Translator): string {
|
||||
const fields = expr.trim().split(/\s+/);
|
||||
if (fields.length !== 5) return expr;
|
||||
const [m, h, dom, mon, dow] = fields as [string, string, string, string, string];
|
||||
const restWild = dom === '*' && mon === '*' && dow === '*';
|
||||
const domMonWild = dom === '*' && mon === '*';
|
||||
|
||||
if (m === '*' && h === '*' && restWild) return t('conversation.cron.everyMinute');
|
||||
|
||||
const everyNMin = /^\*\/(\d+)$/.exec(m);
|
||||
if (everyNMin && h === '*' && restWild) {
|
||||
if (everyNMin[1] === '1') return t('conversation.cron.everyMinute');
|
||||
return t('conversation.cron.everyNMinutes', { n: everyNMin[1]! });
|
||||
}
|
||||
|
||||
if (m === '0' && h === '*' && restWild) return t('conversation.cron.everyHour');
|
||||
|
||||
const everyNHour = /^\*\/(\d+)$/.exec(h);
|
||||
if (m === '0' && everyNHour && restWild) {
|
||||
return t('conversation.cron.everyNHours', { n: everyNHour[1]! });
|
||||
}
|
||||
|
||||
if (isNum(m) && isNum(h) && domMonWild) {
|
||||
const time = clockTime(h, m);
|
||||
if (dow === '1-5') return t('conversation.cron.weekdaysAt', { time });
|
||||
if (dow === '*') return t('conversation.cron.dailyAt', { time });
|
||||
}
|
||||
|
||||
return expr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a cron prompt for the notice card: keep only the first line, and if
|
||||
* that line itself exceeds `limit`, slice it with an ellipsis. `hasMore` tells
|
||||
* the caller whether to render the expand toggle (a newline or over-long text).
|
||||
* Without the length slice, a long one-line prompt would render in full even in
|
||||
* the "collapsed" state and make the toggle a no-op.
|
||||
*/
|
||||
export function collapsePrompt(
|
||||
text: string,
|
||||
limit = 120,
|
||||
): { text: string; hasMore: boolean } {
|
||||
const firstLine = text.split('\n')[0] ?? '';
|
||||
const hasMore = text.includes('\n') || text.length > limit;
|
||||
const collapsed =
|
||||
firstLine.length > limit ? `${firstLine.slice(0, limit).trimEnd()}…` : firstLine;
|
||||
return { text: collapsed, hasMore };
|
||||
}
|
||||
|
|
@ -184,7 +184,7 @@ export type ApprovalBlock =
|
|||
}
|
||||
| { kind: 'generic'; summary: string };
|
||||
|
||||
export type TurnRole = 'user' | 'assistant' | 'compaction';
|
||||
export type TurnRole = 'user' | 'assistant' | 'compaction' | 'cron';
|
||||
|
||||
export interface FilePreviewRequest {
|
||||
path: string;
|
||||
|
|
@ -207,6 +207,18 @@ export interface ToolDiffTarget {
|
|||
output?: string[];
|
||||
}
|
||||
|
||||
/** Metadata carried by a cron fire — shared by a standalone cron turn and by a
|
||||
* cron notice embedded inside an assistant turn's blocks. Mirrors the TUI's
|
||||
* CronTranscriptData. `missedCount` present means a missed-fire catch-up. */
|
||||
export interface CronTurnData {
|
||||
jobId?: string;
|
||||
cron?: string;
|
||||
recurring?: boolean;
|
||||
coalescedCount?: number;
|
||||
stale?: boolean;
|
||||
missedCount?: number;
|
||||
}
|
||||
|
||||
/** One ordered piece of an assistant turn: a thinking segment, a text segment
|
||||
* OR a tool card. Built in call order so every piece renders inline where it
|
||||
* happened (a turn can think → act → think again — nothing is hoisted).
|
||||
|
|
@ -217,7 +229,8 @@ export interface ToolDiffTarget {
|
|||
export type TurnBlock =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'thinking'; thinking: string }
|
||||
| { kind: 'tool'; tool: ToolCall };
|
||||
| { kind: 'tool'; tool: ToolCall }
|
||||
| { kind: 'cron'; text: string; cron: CronTurnData };
|
||||
|
||||
export interface ChatTurn {
|
||||
id: string;
|
||||
|
|
@ -248,6 +261,10 @@ export interface ChatTurn {
|
|||
/** Plugin command metadata: when a user turn was triggered by a plugin slash
|
||||
command (/plugin:command), this holds the command identity and args. */
|
||||
pluginCommand?: { pluginId: string; commandName: string; args?: string };
|
||||
/** Cron fire metadata (role 'cron'): set when an agent turn was triggered by a
|
||||
scheduled reminder rather than a real user. Mirrors the TUI's
|
||||
CronTranscriptData. `missedCount` present means a missed-fire catch-up. */
|
||||
cron?: CronTurnData;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { createAgentProjector, subagentProgressText } from '../src/api/daemon/agentEventProjector';
|
||||
import { classifyFrame, createAgentProjector, subagentProgressText } from '../src/api/daemon/agentEventProjector';
|
||||
|
||||
describe('subagentProgressText', () => {
|
||||
it('drops turn.step.started as noise', () => {
|
||||
|
|
@ -60,3 +60,77 @@ describe('subagent streaming text', () => {
|
|||
expect(events).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cron.fired', () => {
|
||||
it('synthesizes a user message so the cron notice renders live', () => {
|
||||
const projector = createAgentProjector();
|
||||
const events = projector.project(
|
||||
'cron.fired',
|
||||
{
|
||||
origin: {
|
||||
kind: 'cron_job',
|
||||
jobId: 'a3f9c2',
|
||||
cron: '*/5 * * * *',
|
||||
recurring: true,
|
||||
coalescedCount: 2,
|
||||
stale: false,
|
||||
},
|
||||
prompt: 'Check the deploy status',
|
||||
},
|
||||
's1',
|
||||
);
|
||||
const created = events.find((e) => e.type === 'messageCreated');
|
||||
expect(created).toBeDefined();
|
||||
expect(created).toMatchObject({
|
||||
type: 'messageCreated',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Check the deploy status' }],
|
||||
metadata: { origin: { kind: 'cron_job', jobId: 'a3f9c2' } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores cron.fired events missing a prompt or a cron_job origin', () => {
|
||||
const projector = createAgentProjector();
|
||||
expect(projector.project('cron.fired', { origin: { kind: 'cron_job' } }, 's1')).toEqual([]);
|
||||
expect(projector.project('cron.fired', { prompt: 'hi' }, 's1')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cron.fired prompt id isolation', () => {
|
||||
it('omits promptId so the synthesized notice does not clobber the abort cache', () => {
|
||||
const projector = createAgentProjector();
|
||||
projector.project(
|
||||
'prompt.submitted',
|
||||
{ promptId: 'pr_user', userMessageId: 'u1', content: [{ type: 'text', text: 'hi' }] },
|
||||
's1',
|
||||
);
|
||||
const events = projector.project(
|
||||
'cron.fired',
|
||||
{
|
||||
origin: {
|
||||
kind: 'cron_job',
|
||||
jobId: 'j',
|
||||
cron: '* * * * *',
|
||||
recurring: true,
|
||||
coalescedCount: 1,
|
||||
stale: false,
|
||||
},
|
||||
prompt: 'Check the deploy status',
|
||||
},
|
||||
's1',
|
||||
);
|
||||
const created = events.find((e) => e.type === 'messageCreated');
|
||||
expect(created).toBeDefined();
|
||||
expect((created as { message: { promptId?: string } }).message.promptId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyFrame cron.fired', () => {
|
||||
it('routes both raw and event.-prefixed cron.fired to the agent projector', () => {
|
||||
const payload = { origin: { kind: 'cron_job' }, prompt: 'x' };
|
||||
expect(classifyFrame('cron.fired', payload)).toEqual({ route: 'agent', agentType: 'cron.fired' });
|
||||
expect(classifyFrame('event.cron.fired', payload)).toEqual({ route: 'agent', agentType: 'cron.fired' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -307,3 +307,49 @@ describe('reduceAppEvent sessions reference stability', () => {
|
|||
expect(next.sessions.map((s) => s.id)).toEqual(['s2', 's1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduceAppEvent messageCreated cron origin', () => {
|
||||
it('appends a cron-origin user message instead of reconciling it into an optimistic echo', () => {
|
||||
const sid = 's-cron';
|
||||
const optimistic: AppMessage = {
|
||||
id: 'opt_1',
|
||||
sessionId: sid,
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'check the BTC price' }],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
promptId: 'pr_user',
|
||||
metadata: { 'kimiWeb.optimisticUserMessage': true },
|
||||
};
|
||||
const state = {
|
||||
...createInitialState(),
|
||||
sessions: [makeSession(sid, '2026-01-01T00:00:00.000Z')],
|
||||
messagesBySession: { [sid]: [optimistic] },
|
||||
};
|
||||
const cronMessage: AppMessage = {
|
||||
id: 'cron_1',
|
||||
sessionId: sid,
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'check the BTC price' }],
|
||||
createdAt: '2026-01-01T00:01:00.000Z',
|
||||
promptId: 'cron_pr_x',
|
||||
metadata: {
|
||||
origin: {
|
||||
kind: 'cron_job',
|
||||
jobId: 'j',
|
||||
cron: '* * * * *',
|
||||
recurring: true,
|
||||
coalescedCount: 1,
|
||||
stale: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const next = reduceAppEvent(
|
||||
state,
|
||||
{ type: 'messageCreated', message: cronMessage },
|
||||
{ sessionId: sid, seq: 2 },
|
||||
);
|
||||
const msgs = next.messagesBySession[sid]!;
|
||||
expect(msgs).toHaveLength(2);
|
||||
expect(msgs.map((m) => m.id)).toEqual(['opt_1', 'cron_1']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { buildEditDiffLines } from '../src/lib/toolDiff';
|
|||
import { createCoalescedAsyncRunner } from '../src/lib/snapshotSync';
|
||||
import { mergeSnapshotMessages } from '../src/lib/snapshotMessages';
|
||||
import { normalizeToolName, toolSummary } from '../src/lib/toolMeta';
|
||||
import { collapsePrompt, humanizeCron } from '../src/lib/cronHumanize';
|
||||
import {
|
||||
coerceThinkingForModel,
|
||||
commitLevel,
|
||||
|
|
@ -371,6 +372,64 @@ describe('modelThinking', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('humanizeCron', () => {
|
||||
const dict: Record<string, string> = {
|
||||
'conversation.cron.everyMinute': 'Every minute',
|
||||
'conversation.cron.everyNMinutes': 'Every {n} minutes',
|
||||
'conversation.cron.everyHour': 'Every hour',
|
||||
'conversation.cron.everyNHours': 'Every {n} hours',
|
||||
'conversation.cron.dailyAt': 'Daily at {time}',
|
||||
'conversation.cron.weekdaysAt': 'Weekdays at {time}',
|
||||
};
|
||||
const t = (key: string, params?: Record<string, unknown>): string => {
|
||||
let s = dict[key] ?? key;
|
||||
if (params) for (const [k, v] of Object.entries(params)) s = s.replace(`{${k}}`, String(v));
|
||||
return s;
|
||||
};
|
||||
|
||||
it('labels the common cadences', () => {
|
||||
expect(humanizeCron('* * * * *', t)).toBe('Every minute');
|
||||
expect(humanizeCron('*/5 * * * *', t)).toBe('Every 5 minutes');
|
||||
expect(humanizeCron('*/1 * * * *', t)).toBe('Every minute');
|
||||
expect(humanizeCron('0 * * * *', t)).toBe('Every hour');
|
||||
expect(humanizeCron('0 */2 * * *', t)).toBe('Every 2 hours');
|
||||
});
|
||||
|
||||
it('labels fixed daily and weekday times', () => {
|
||||
expect(humanizeCron('5 9 * * *', t)).toBe('Daily at 9:05');
|
||||
expect(humanizeCron('0 9 * * 1-5', t)).toBe('Weekdays at 9:00');
|
||||
});
|
||||
|
||||
it('falls back to the raw expression for unrecognized shapes', () => {
|
||||
expect(humanizeCron('0 9 1 * *', t)).toBe('0 9 1 * *');
|
||||
expect(humanizeCron('bad', t)).toBe('bad');
|
||||
});
|
||||
});
|
||||
|
||||
describe('collapsePrompt', () => {
|
||||
it('keeps a short single-line prompt intact with no expand toggle', () => {
|
||||
expect(collapsePrompt('Check the deploy status')).toEqual({
|
||||
text: 'Check the deploy status',
|
||||
hasMore: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('truncates a long one-line prompt with an ellipsis and reports hasMore', () => {
|
||||
const long = 'a'.repeat(150);
|
||||
const result = collapsePrompt(long, 120);
|
||||
expect(result.hasMore).toBe(true);
|
||||
expect(result.text.length).toBeLessThan(long.length);
|
||||
expect(result.text.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('shows only the first line for a multi-line prompt', () => {
|
||||
expect(collapsePrompt('first line\nsecond line\nthird line')).toEqual({
|
||||
text: 'first line',
|
||||
hasMore: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeSnapshotMessages', () => {
|
||||
function msg(id: string, createdAt: string): AppMessage {
|
||||
return { id, sessionId: 's1', role: 'assistant', content: [], createdAt };
|
||||
|
|
|
|||
|
|
@ -235,3 +235,169 @@ describe('latestTodos', () => {
|
|||
).toEqual([{ title: 'new', status: 'done' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('messagesToTurns cron', () => {
|
||||
it('renders a cron_job injection as a cron notice with the unwrapped prompt', () => {
|
||||
const envelope =
|
||||
'<cron-fire jobId="a3f9c2" cron="*/5 * * * *" recurring="true" coalescedCount="2" stale="false">\n' +
|
||||
'<prompt>\nCheck the deploy status\n</prompt>\n</cron-fire>';
|
||||
const turns = messagesToTurns(
|
||||
[
|
||||
message('c1', 'user', [{ type: 'text', text: envelope }], {
|
||||
metadata: {
|
||||
origin: {
|
||||
kind: 'cron_job',
|
||||
jobId: 'a3f9c2',
|
||||
cron: '*/5 * * * *',
|
||||
recurring: true,
|
||||
coalescedCount: 2,
|
||||
stale: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(turns).toHaveLength(1);
|
||||
expect(turns[0]).toMatchObject({
|
||||
role: 'cron',
|
||||
text: 'Check the deploy status',
|
||||
cron: {
|
||||
jobId: 'a3f9c2',
|
||||
cron: '*/5 * * * *',
|
||||
recurring: true,
|
||||
coalescedCount: 2,
|
||||
stale: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a cron_missed injection as a cron notice carrying the missed count', () => {
|
||||
const envelope = '<cron-fire missed="3">\nDaily report\n</cron-fire>';
|
||||
const turns = messagesToTurns(
|
||||
[
|
||||
message('c2', 'user', [{ type: 'text', text: envelope }], {
|
||||
metadata: { origin: { kind: 'cron_missed', count: 3 } },
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(turns).toHaveLength(1);
|
||||
expect(turns[0]).toMatchObject({
|
||||
role: 'cron',
|
||||
text: 'Daily report',
|
||||
cron: { missedCount: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not also render a user bubble for a cron injection', () => {
|
||||
const turns = messagesToTurns(
|
||||
[
|
||||
message(
|
||||
'c3',
|
||||
'user',
|
||||
[{ type: 'text', text: '<cron-fire>\n<prompt>\nhi\n</prompt>\n</cron-fire>' }],
|
||||
{
|
||||
metadata: {
|
||||
origin: {
|
||||
kind: 'cron_job',
|
||||
jobId: 'j',
|
||||
cron: '* * * * *',
|
||||
recurring: true,
|
||||
coalescedCount: 1,
|
||||
stale: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(turns.some((t) => t.role === 'user')).toBe(false);
|
||||
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 =
|
||||
'<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: 'text', text: 'answer' }]),
|
||||
message('c1', 'user', [{ type: 'text', text: envelope }], {
|
||||
metadata: {
|
||||
origin: {
|
||||
kind: 'cron_job',
|
||||
jobId: 'j',
|
||||
cron: '* * * * *',
|
||||
recurring: true,
|
||||
coalescedCount: 1,
|
||||
stale: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
message('a2', 'assistant', [{ type: 'text', text: 'btc is 62k' }]),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
// No prompt ids anywhere (REST-shaped): the cron still becomes its own
|
||||
// turn, and the cron-triggered reply does not merge into the first answer.
|
||||
expect(turns.map((t) => t.role)).toEqual(['user', 'assistant', 'cron', 'assistant']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue