From 87ab12849c8ebffd3a9bf5af916f526cf71fef10 Mon Sep 17 00:00:00 2001 From: Tao Yiping Date: Sun, 12 Apr 2026 11:51:29 +0800 Subject: [PATCH] feat(cron): allow users to associate cron jobs with specific agents (#835) --- electron/api/routes/cron.ts | 17 +++ src/i18n/locales/en/cron.json | 1 + src/i18n/locales/ja/cron.json | 1 + src/i18n/locales/ru/cron.json | 1 + src/i18n/locales/zh/cron.json | 1 + src/pages/Cron/index.tsx | 34 +++++ src/stores/cron.ts | 105 +++++++++++-- src/types/cron.ts | 3 + tests/unit/cron-store.test.ts | 276 ++++++++++++++++++++++++++++++++++ 9 files changed, 429 insertions(+), 10 deletions(-) create mode 100644 tests/unit/cron-store.test.ts diff --git a/electron/api/routes/cron.ts b/electron/api/routes/cron.ts index 2292bc13a..24d85144b 100644 --- a/electron/api/routes/cron.ts +++ b/electron/api/routes/cron.ts @@ -347,6 +347,14 @@ function buildCronUpdatePatch(input: Record): Record(req); + const agentId = typeof input.agentId === 'string' && input.agentId.trim() + ? input.agentId.trim() + : 'main'; const delivery = normalizeCronDelivery(input.delivery); const unsupportedDeliveryError = getUnsupportedCronDeliveryError(delivery.channel); if (delivery.mode === 'announce' && unsupportedDeliveryError) { @@ -529,6 +545,7 @@ export async function handleCronRoutes( enabled: input.enabled ?? true, wakeMode: 'next-heartbeat', sessionTarget: 'isolated', + agentId, delivery, }); sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result); diff --git a/src/i18n/locales/en/cron.json b/src/i18n/locales/en/cron.json index 8c1f63d13..f00822ee7 100644 --- a/src/i18n/locales/en/cron.json +++ b/src/i18n/locales/en/cron.json @@ -29,6 +29,7 @@ "taskNamePlaceholder": "e.g., Morning briefing", "message": "Message / Prompt", "messagePlaceholder": "What should the AI do? e.g., Give me a summary of today's news and weather", + "agent": "Agent", "schedule": "Schedule", "cronPlaceholder": "Cron expression (e.g., 0 9 * * *)", "usePresets": "Use presets", diff --git a/src/i18n/locales/ja/cron.json b/src/i18n/locales/ja/cron.json index b1abf698b..8daaa45e5 100644 --- a/src/i18n/locales/ja/cron.json +++ b/src/i18n/locales/ja/cron.json @@ -29,6 +29,7 @@ "taskNamePlaceholder": "例:朝のブリーフィング", "message": "メッセージ / プロンプト", "messagePlaceholder": "AIに何をさせますか? 例:今日のニュースと天気のまとめを作成", + "agent": "エージェント", "schedule": "スケジュール", "cronPlaceholder": "Cron式(例:0 9 * * *)", "usePresets": "プリセットを使用", diff --git a/src/i18n/locales/ru/cron.json b/src/i18n/locales/ru/cron.json index 2b806ec58..8b5a751e1 100644 --- a/src/i18n/locales/ru/cron.json +++ b/src/i18n/locales/ru/cron.json @@ -29,6 +29,7 @@ "taskNamePlaceholder": "напр., Утренняя сводка", "message": "Сообщение / Промпт", "messagePlaceholder": "Что должен сделать AI? напр., Дай мне сводку новостей и погоды на сегодня", + "agent": "Агент", "schedule": "Расписание", "cronPlaceholder": "Cron-выражение (напр., 0 9 * * *)", "usePresets": "Использовать пресеты", diff --git a/src/i18n/locales/zh/cron.json b/src/i18n/locales/zh/cron.json index 30d1f25ac..6783a6637 100644 --- a/src/i18n/locales/zh/cron.json +++ b/src/i18n/locales/zh/cron.json @@ -29,6 +29,7 @@ "taskNamePlaceholder": "例如:早间简报", "message": "消息/提示词", "messagePlaceholder": "AI 应该做什么?例如:给我一份今天的新闻和天气摘要", + "agent": "智能体", "schedule": "调度计划", "cronPlaceholder": "Cron 表达式 (例如:0 9 * * *)", "usePresets": "使用预设", diff --git a/src/pages/Cron/index.tsx b/src/pages/Cron/index.tsx index 93175b9ff..948214a6e 100644 --- a/src/pages/Cron/index.tsx +++ b/src/pages/Cron/index.tsx @@ -20,6 +20,7 @@ import { History, Pause, ChevronDown, + Bot, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -32,6 +33,8 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { hostApiFetch } from '@/lib/host-api'; import { useCronStore } from '@/stores/cron'; import { useGatewayStore } from '@/stores/gateway'; +import { useAgentsStore } from '@/stores/agents'; +import { useChatStore } from '@/stores/chat'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { formatRelativeTime, cn } from '@/lib/utils'; import { toast } from 'sonner'; @@ -243,9 +246,12 @@ interface TaskDialogProps { function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProps) { const { t } = useTranslation('cron'); const [saving, setSaving] = useState(false); + const agents = useAgentsStore((s) => s.agents); const [name, setName] = useState(job?.name || ''); const [message, setMessage] = useState(job?.message || ''); + const [selectedAgentId, setSelectedAgentId] = useState(job?.agentId || useChatStore.getState().currentAgentId); + const [agentIdChanged, setAgentIdChanged] = useState(false); // Extract cron expression string from CronSchedule object or use as-is if string const initialSchedule = (() => { const s = job?.schedule; @@ -405,6 +411,7 @@ function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProp schedule: finalSchedule, delivery: finalDelivery, enabled, + ...(agentIdChanged ? { agentId: selectedAgentId } : {}), }); onClose(); toast.success(job ? t('toast.updated') : t('toast.created')); @@ -453,6 +460,26 @@ function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProp /> + {/* Agent */} +
+ + { + setSelectedAgentId(e.target.value); + setAgentIdChanged(true); + }} + className="h-[44px] rounded-xl border-black/10 dark:border-white/10 bg-[#eeece3] dark:bg-muted text-[13px]" + > + {agents.map((agent) => ( + + ))} + +
+ {/* Schedule */}
@@ -685,6 +712,8 @@ interface CronJobCardProps { function CronJobCard({ job, deliveryAccountName, onToggle, onEdit, onDelete, onTrigger }: CronJobCardProps) { const { t } = useTranslation('cron'); const [triggering, setTriggering] = useState(false); + const agents = useAgentsStore((s) => s.agents); + const agentName = agents.find((a) => a.id === job.agentId)?.name ?? job.agentId; const handleTrigger = async (e: React.MouseEvent) => { e.stopPropagation(); @@ -787,6 +816,11 @@ function CronJobCard({ job, deliveryAccountName, onToggle, onEdit, onDelete, onT {t('card.next')}: {new Date(job.nextRun).toLocaleString()} )} + + + + {agentName} +
{/* Last Run Error */} diff --git a/src/stores/cron.ts b/src/stores/cron.ts index cbae168ea..8f2777f2d 100644 --- a/src/stores/cron.ts +++ b/src/stores/cron.ts @@ -4,6 +4,7 @@ */ import { create } from 'zustand'; import { hostApiFetch } from '@/lib/host-api'; +import { useChatStore } from './chat'; import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron'; interface CronState { @@ -34,10 +35,47 @@ export const useCronStore = create((set) => ({ } else { set({ error: null }); } - + try { const result = await hostApiFetch('/api/cron/jobs'); - set({ jobs: result, loading: false }); + + // If Gateway returned fewer jobs than we have, something might be wrong - preserve all known jobs + // and just update agentIds from localStorage for the ones Gateway returned. + // Priority: API agentId (if non-'main') > currentJobs > localStorage > 'main' + const resultIds = new Set(result.map(j => j.id)); + const savedAgentIdMap = JSON.parse(localStorage.getItem('cronAgentIdMap') || '{}') as Record; + + // Update localStorage agentId map with current data + const newAgentIdMap: Record = {}; + + // For jobs returned by Gateway, restore agentId + const jobsWithAgentId = result.map((job) => { + // Priority: API response (if non-'main') > currentJobs > localStorage > default 'main' + const existingJob = currentJobs.find((j) => j.id === job.id); + const savedAgentId = savedAgentIdMap[job.id]; + let agentId = job.agentId; + if (!agentId || agentId === 'main') { + // API returned 'main' or nothing — use cached value + if (existingJob && existingJob.agentId !== 'main') { + agentId = existingJob.agentId; + } else if (savedAgentId && savedAgentId !== 'main') { + agentId = savedAgentId; + } else { + agentId = 'main'; + } + } + if (agentId !== 'main') { + newAgentIdMap[job.id] = agentId; + } + return { ...job, agentId }; + }); + + // If Gateway returned fewer jobs, preserve extra jobs from current state + const extraJobs = currentJobs.filter(j => !resultIds.has(j.id)); + const allJobs = [...jobsWithAgentId, ...extraJobs]; + + localStorage.setItem('cronAgentIdMap', JSON.stringify(newAgentIdMap)); + set({ jobs: allJobs, loading: false }); } catch (error) { // Preserve previous jobs on error so the user sees stale data instead of nothing. set({ error: String(error), loading: false }); @@ -46,12 +84,19 @@ export const useCronStore = create((set) => ({ createJob: async (input) => { try { + // Auto-capture currentAgentId if not provided + const agentId = input.agentId ?? useChatStore.getState().currentAgentId; const job = await hostApiFetch('/api/cron/jobs', { method: 'POST', - body: JSON.stringify(input), + body: JSON.stringify({ ...input, agentId }), }); - set((state) => ({ jobs: [...state.jobs, job] })); - return job; + const jobWithAgentId = { ...job, agentId }; + // Persist agentId to localStorage (since Gateway doesn't return it) + const savedMap = JSON.parse(localStorage.getItem('cronAgentIdMap') || '{}') as Record; + savedMap[jobWithAgentId.id] = agentId; + localStorage.setItem('cronAgentIdMap', JSON.stringify(savedMap)); + set((state) => ({ jobs: [...state.jobs, jobWithAgentId] })); + return jobWithAgentId; } catch (error) { console.error('Failed to create cron job:', error); throw error; @@ -60,13 +105,44 @@ export const useCronStore = create((set) => ({ updateJob: async (id, input) => { try { + const currentJob = useCronStore.getState().jobs.find((j) => j.id === id); + const newAgentId = input.agentId; + + // If agentId changed, recreate with new agentId first then delete old one (Gateway doesn't support updating sessionTarget) + if (newAgentId && currentJob && newAgentId !== currentJob.agentId) { + // Create new job with new agentId first (preserves schedule on failure) + const { agentId: _agentId, ...restInput } = input; + const newJob = await hostApiFetch('/api/cron/jobs', { + method: 'POST', + body: JSON.stringify({ ...restInput, agentId: newAgentId }), + }); + const jobWithAgentId = { ...currentJob, ...newJob, agentId: newAgentId }; + // Update localStorage: add new id first, then remove old id + const savedMap = JSON.parse(localStorage.getItem('cronAgentIdMap') || '{}') as Record; + savedMap[jobWithAgentId.id] = newAgentId; + localStorage.setItem('cronAgentIdMap', JSON.stringify(savedMap)); + // Delete old job after new one is created successfully + await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); + delete savedMap[id]; + localStorage.setItem('cronAgentIdMap', JSON.stringify(savedMap)); + set((state) => ({ + jobs: state.jobs.map((j) => (j.id === id ? jobWithAgentId : j)), + })); + return; + } + + // Normal update for other fields - use currentJob as base, overlay updatedJob to preserve fields const updatedJob = await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify(input), }); + // Merge: updatedJob fields override currentJob, but preserve currentJob fields not in updatedJob + const jobWithAgentId = { ...currentJob, ...updatedJob, agentId: currentJob?.agentId ?? updatedJob.agentId }; set((state) => ({ jobs: state.jobs.map((job) => - job.id === id ? updatedJob : job + job.id === id ? jobWithAgentId : job ), })); } catch (error) { @@ -80,6 +156,10 @@ export const useCronStore = create((set) => ({ await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(id)}`, { method: 'DELETE', }); + // Remove from localStorage + const savedMap = JSON.parse(localStorage.getItem('cronAgentIdMap') || '{}') as Record; + delete savedMap[id]; + localStorage.setItem('cronAgentIdMap', JSON.stringify(savedMap)); set((state) => ({ jobs: state.jobs.filter((job) => job.id !== id), })); @@ -108,15 +188,20 @@ export const useCronStore = create((set) => ({ triggerJob: async (id) => { try { - const result = await hostApiFetch('/api/cron/trigger', { + await hostApiFetch('/api/cron/trigger', { method: 'POST', body: JSON.stringify({ id }), }); - console.log('Cron trigger result:', result); // Refresh jobs after trigger to update lastRun/nextRun state try { - const jobs = await hostApiFetch('/api/cron/jobs'); - set({ jobs }); + const currentJobs = useCronStore.getState().jobs; + const resultJobs = await hostApiFetch('/api/cron/jobs'); + // Preserve agentId from existing jobs + const jobsWithAgentId = resultJobs.map((job) => { + const existing = currentJobs.find((j) => j.id === job.id); + return existing ? { ...job, agentId: existing.agentId } : job; + }); + set({ jobs: jobsWithAgentId }); } catch { // Ignore refresh error } diff --git a/src/types/cron.ts b/src/types/cron.ts index affe5d261..532ecfad2 100644 --- a/src/types/cron.ts +++ b/src/types/cron.ts @@ -58,6 +58,7 @@ export interface CronJob { updatedAt: string; lastRun?: CronJobLastRun; nextRun?: string; + agentId: string; } /** @@ -69,6 +70,7 @@ export interface CronJobCreateInput { schedule: string; delivery?: CronJobDelivery; enabled?: boolean; + agentId?: string; } /** @@ -80,6 +82,7 @@ export interface CronJobUpdateInput { schedule?: string; delivery?: CronJobDelivery; enabled?: boolean; + agentId?: string; } /** diff --git a/tests/unit/cron-store.test.ts b/tests/unit/cron-store.test.ts new file mode 100644 index 000000000..5c96894ad --- /dev/null +++ b/tests/unit/cron-store.test.ts @@ -0,0 +1,276 @@ +/** + * Cron Store Tests + */ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { useCronStore } from '@/stores/cron'; +import { useChatStore } from '@/stores/chat'; + +// Mock hostApiFetch +const mockHostApiFetch = vi.fn(); +vi.mock('@/lib/host-api', () => ({ + hostApiFetch: (...args: unknown[]) => mockHostApiFetch(...args), +})); + +// Mock localStorage +const localStorageMock = { + data: {} as Record, + getItem: vi.fn((key: string) => localStorageMock.data[key] ?? null), + setItem: vi.fn((key: string, value: string) => { localStorageMock.data[key] = value; }), + removeItem: vi.fn((key: string) => { delete localStorageMock.data[key]; }), + clear: vi.fn(() => { localStorageMock.data = {}; }), +}; +Object.defineProperty(global, 'localStorage', { value: localStorageMock }); + +describe('Cron Store', () => { + beforeEach(() => { + vi.resetAllMocks(); + localStorageMock.data = {}; + // Reset stores to default state + useCronStore.setState({ jobs: [], loading: false, error: null }); + useChatStore.setState({ currentAgentId: 'main', currentSessionKey: 'agent:main:session-1' }); + }); + + afterEach(() => { + localStorageMock.data = {}; + }); + + describe('fetchJobs', () => { + it('preserves agentId from localStorage when Gateway does not return agentId', async () => { + // Pre-populate localStorage with job -> agentId mapping + localStorageMock.data['cronAgentIdMap'] = JSON.stringify({ + 'job-1': 'typ-2', + 'job-2': 'agent-3', + }); + + // Gateway returns jobs WITHOUT agentId field + mockHostApiFetch.mockResolvedValueOnce([ + { id: 'job-1', name: 'Job 1', agentId: 'main', schedule: '0 9 * * *', enabled: true, message: 'Hi', delivery: { mode: 'none' }, createdAt: '', updatedAt: '' }, + { id: 'job-2', name: 'Job 2', agentId: 'main', schedule: '0 10 * * *', enabled: true, message: 'Hi', delivery: { mode: 'none' }, createdAt: '', updatedAt: '' }, + ]); + + await useCronStore.getState().fetchJobs(); + + const jobs = useCronStore.getState().jobs; + expect(jobs.find(j => j.id === 'job-1')?.agentId).toBe('typ-2'); + expect(jobs.find(j => j.id === 'job-2')?.agentId).toBe('agent-3'); + }); + + it('preserves extra jobs not returned by Gateway', async () => { + // Pre-populate localStorage + localStorageMock.data['cronAgentIdMap'] = JSON.stringify({}); + + // Set existing job in store + useCronStore.setState({ + jobs: [ + { id: 'job-extra', name: 'Extra Job', agentId: 'typ-2', schedule: '0 9 * * *', enabled: true, message: 'Hi', delivery: { mode: 'none' }, createdAt: '', updatedAt: '' }, + ], + }); + + // Gateway returns fewer jobs (missing job-extra) + mockHostApiFetch.mockResolvedValueOnce([ + { id: 'job-1', name: 'Job 1', agentId: 'main', schedule: '0 9 * * *', enabled: true, message: 'Hi', delivery: { mode: 'none' }, createdAt: '', updatedAt: '' }, + ]); + + await useCronStore.getState().fetchJobs(); + + const jobs = useCronStore.getState().jobs; + expect(jobs.length).toBe(2); + expect(jobs.find(j => j.id === 'job-extra')).toBeDefined(); + }); + + it('defaults to main agent when localStorage has no mapping', async () => { + mockHostApiFetch.mockResolvedValueOnce([ + { id: 'job-1', name: 'Job 1', agentId: 'main', schedule: '0 9 * * *', enabled: true, message: 'Hi', delivery: { mode: 'none' }, createdAt: '', updatedAt: '' }, + ]); + + await useCronStore.getState().fetchJobs(); + + const jobs = useCronStore.getState().jobs; + expect(jobs[0].agentId).toBe('main'); + }); + }); + + describe('createJob', () => { + it('auto-captures currentAgentId when agentId is not provided', async () => { + mockHostApiFetch.mockResolvedValueOnce({ + id: 'new-job', + name: 'New Job', + schedule: { kind: 'cron', expr: '0 9 * * *' }, + enabled: true, + message: 'Hi', + delivery: { mode: 'none' }, + createdAt: '', + updatedAt: '', + }); + + useChatStore.setState({ currentAgentId: 'typ-2' }); + + await useCronStore.getState().createJob({ + name: 'New Job', + message: 'Hi', + schedule: '0 9 * * *', + }); + + // Verify agentId was sent to API + const [, init] = mockHostApiFetch.mock.calls[0] as [string, Record]; + expect((init as { body: string }).body).toContain('"agentId":"typ-2"'); + + // Verify localStorage was updated + expect(localStorageMock.data['cronAgentIdMap']).toContain('typ-2'); + }); + + it('uses provided agentId when explicitly passed', async () => { + mockHostApiFetch.mockResolvedValueOnce({ + id: 'new-job', + name: 'New Job', + schedule: { kind: 'cron', expr: '0 9 * * *' }, + enabled: true, + message: 'Hi', + delivery: { mode: 'none' }, + createdAt: '', + updatedAt: '', + }); + + await useCronStore.getState().createJob({ + name: 'New Job', + message: 'Hi', + schedule: '0 9 * * *', + agentId: 'agent-5', + }); + + const [, init] = mockHostApiFetch.mock.calls[0] as [string, Record]; + expect((init as { body: string }).body).toContain('"agentId":"agent-5"'); + }); + + it('persists agentId to localStorage', async () => { + mockHostApiFetch.mockResolvedValueOnce({ + id: 'job-xyz', + name: 'Job', + schedule: { kind: 'cron', expr: '0 9 * * *' }, + enabled: true, + message: 'Hi', + delivery: { mode: 'none' }, + createdAt: '', + updatedAt: '', + }); + + useChatStore.setState({ currentAgentId: 'custom-agent' }); + + await useCronStore.getState().createJob({ + name: 'Job', + message: 'Hi', + schedule: '0 9 * * *', + }); + + const savedMap = JSON.parse(localStorageMock.data['cronAgentIdMap'] || '{}'); + expect(savedMap['job-xyz']).toBe('custom-agent'); + }); + }); + + describe('updateJob', () => { + it('preserves agentId from currentJob when updating other fields', async () => { + useCronStore.setState({ + jobs: [ + { id: 'job-1', name: 'Old Name', agentId: 'typ-2', schedule: '0 9 * * *', enabled: true, message: 'Hi', delivery: { mode: 'none' }, createdAt: '', updatedAt: '' }, + ], + }); + + // PUT returns job with updated fields but missing agentId + mockHostApiFetch.mockResolvedValueOnce({ + id: 'job-1', + name: 'New Name', + schedule: { kind: 'cron', expr: '0 9 * * *' }, + enabled: true, + message: 'Updated', + delivery: { mode: 'none' }, + createdAt: '', + updatedAt: '', + }); + + await useCronStore.getState().updateJob('job-1', { + name: 'New Name', + message: 'Updated', + schedule: '0 9 * * *', + }); + + const job = useCronStore.getState().jobs.find(j => j.id === 'job-1'); + expect(job?.agentId).toBe('typ-2'); + expect(job?.name).toBe('New Name'); + }); + + it('deletes and recreates job when agentId changes', async () => { + useCronStore.setState({ + jobs: [ + { id: 'job-1', name: 'Job', agentId: 'main', schedule: '0 9 * * *', enabled: true, message: 'Hi', delivery: { mode: 'none' }, createdAt: '', updatedAt: '' }, + ], + }); + + // POST call first (create new job before deleting old one) + mockHostApiFetch.mockResolvedValueOnce({ + id: 'job-new', + name: 'Job', + schedule: { kind: 'cron', expr: '0 9 * * *' }, + enabled: true, + message: 'Hi', + delivery: { mode: 'none' }, + createdAt: '', + updatedAt: '', + }); + // DELETE call (delete old job after new one is created) + mockHostApiFetch.mockResolvedValueOnce({}); + + await useCronStore.getState().updateJob('job-1', { + name: 'Job', + message: 'Hi', + schedule: '0 9 * * *', + agentId: 'new-agent', + }); + + // Should have POST and DELETE calls + expect(mockHostApiFetch).toHaveBeenCalledTimes(2); + + // Verify localStorage updated with new job id + const savedMap = JSON.parse(localStorageMock.data['cronAgentIdMap'] || '{}'); + expect(savedMap['job-1']).toBeUndefined(); + expect(savedMap['job-new']).toBe('new-agent'); + }); + }); + + describe('deleteJob', () => { + it('removes job from localStorage on delete', async () => { + localStorageMock.data['cronAgentIdMap'] = JSON.stringify({ + 'job-1': 'typ-2', + 'job-2': 'main', + }); + + mockHostApiFetch.mockResolvedValueOnce({}); + + await useCronStore.getState().deleteJob('job-1'); + + const savedMap = JSON.parse(localStorageMock.data['cronAgentIdMap'] || '{}'); + expect(savedMap['job-1']).toBeUndefined(); + expect(savedMap['job-2']).toBe('main'); + }); + }); + + describe('triggerJob', () => { + it('preserves agentId from currentJobs after refresh', async () => { + useCronStore.setState({ + jobs: [ + { id: 'job-trigger', name: 'Triggered', agentId: 'typ-2', schedule: '0 9 * * *', enabled: true, message: 'Hi', delivery: { mode: 'none' }, createdAt: '', updatedAt: '' }, + ], + }); + + mockHostApiFetch.mockResolvedValueOnce({}); // trigger call + // fetchJobs after trigger returns same job but without agentId + mockHostApiFetch.mockResolvedValueOnce([ + { id: 'job-trigger', name: 'Triggered', agentId: 'main', schedule: '0 9 * * *', enabled: true, message: 'Hi', delivery: { mode: 'none' }, createdAt: '', updatedAt: '', lastRun: { time: new Date().toISOString(), success: true } }, + ]); + + await useCronStore.getState().triggerJob('job-trigger'); + + const job = useCronStore.getState().jobs.find(j => j.id === 'job-trigger'); + expect(job?.agentId).toBe('typ-2'); + }); + }); +});