import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { IncomingMessage, ServerResponse } from 'http'; const parseJsonBodyMock = vi.fn(); const sendJsonMock = vi.fn(); const { readFileMock } = vi.hoisted(() => ({ readFileMock: vi.fn(), })); vi.mock('@electron/api/route-utils', () => ({ parseJsonBody: (...args: unknown[]) => parseJsonBodyMock(...args), sendJson: (...args: unknown[]) => sendJsonMock(...args), })); vi.mock('node:fs/promises', async () => { const actual = await vi.importActual('node:fs/promises'); return { ...actual, readFile: readFileMock, default: { ...actual, readFile: readFileMock, }, }; }); describe('handleCronRoutes', () => { beforeEach(() => { vi.resetAllMocks(); readFileMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); }); it('creates cron jobs with external delivery configuration', async () => { parseJsonBodyMock.mockResolvedValue({ name: 'Weather delivery', message: 'Summarize today', schedule: '0 9 * * *', delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_weather', }, enabled: true, }); const rpc = vi.fn().mockResolvedValue({ id: 'job-1', name: 'Weather delivery', enabled: true, createdAtMs: 1, updatedAtMs: 2, schedule: { kind: 'cron', expr: '0 9 * * *' }, payload: { kind: 'agentTurn', message: 'Summarize today' }, delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_weather' }, state: {}, }); const { handleCronRoutes } = await import('@electron/api/routes/cron'); const handled = await handleCronRoutes( { method: 'POST' } as IncomingMessage, {} as ServerResponse, new URL('http://127.0.0.1:13210/api/cron/jobs'), { gatewayManager: { rpc }, } as never, ); expect(handled).toBe(true); expect(rpc).toHaveBeenCalledWith('cron.add', expect.objectContaining({ delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_weather' }, payload: { kind: 'agentTurn', message: expect.stringContaining('Summarize today'), }, })); expect((rpc.mock.calls[0]?.[1] as { payload: { message: string } }).payload.message).not.toContain('YINIAN_BUSINESS_RESPONSE_GUIDANCE'); expect(sendJsonMock).toHaveBeenCalledWith( expect.anything(), 200, expect.objectContaining({ id: 'job-1', delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_weather' }, }), ); }); it('updates cron jobs with transformed payload and delivery fields', async () => { parseJsonBodyMock.mockResolvedValue({ message: 'Updated prompt', delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_next', }, }); const rpc = vi.fn().mockResolvedValue({ id: 'job-2', name: 'Updated job', enabled: true, createdAtMs: 1, updatedAtMs: 3, schedule: { kind: 'cron', expr: '0 9 * * *' }, payload: { kind: 'agentTurn', message: 'Updated prompt' }, delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_next' }, state: {}, }); const { handleCronRoutes } = await import('@electron/api/routes/cron'); await handleCronRoutes( { method: 'PUT' } as IncomingMessage, {} as ServerResponse, new URL('http://127.0.0.1:13210/api/cron/jobs/job-2'), { gatewayManager: { rpc }, } as never, ); expect(rpc).toHaveBeenCalledWith('cron.update', { id: 'job-2', patch: { payload: { kind: 'agentTurn', message: expect.stringContaining('Updated prompt') }, delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_next' }, }, }); expect((rpc.mock.calls[0]?.[1] as { patch: { payload: { message: string } } }).patch.payload.message).not.toContain('YINIAN_BUSINESS_RESPONSE_GUIDANCE'); expect(sendJsonMock).toHaveBeenCalledWith( expect.anything(), 200, expect.objectContaining({ id: 'job-2', message: 'Updated prompt', delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_next' }, }), ); }); it('passes through delivery.accountId for multi-account cron jobs', async () => { parseJsonBodyMock.mockResolvedValue({ delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_owner', accountId: 'feishu-0d009958', }, }); const rpc = vi.fn().mockResolvedValue({ id: 'job-account', name: 'Account job', enabled: true, createdAtMs: 1, updatedAtMs: 4, schedule: { kind: 'cron', expr: '0 9 * * *' }, payload: { kind: 'agentTurn', message: 'Prompt' }, delivery: { mode: 'announce', channel: 'feishu', accountId: 'feishu-0d009958', to: 'user:ou_owner' }, state: {}, }); const { handleCronRoutes } = await import('@electron/api/routes/cron'); await handleCronRoutes( { method: 'PUT' } as IncomingMessage, {} as ServerResponse, new URL('http://127.0.0.1:13210/api/cron/jobs/job-account'), { gatewayManager: { rpc }, } as never, ); expect(rpc).toHaveBeenCalledWith('cron.update', { id: 'job-account', patch: { delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_owner', accountId: 'feishu-0d009958', }, }, }); }); it('allows WeChat scheduled delivery', async () => { parseJsonBodyMock.mockResolvedValue({ name: 'WeChat delivery', message: 'Send update', schedule: '0 10 * * *', delivery: { mode: 'announce', channel: 'wechat', to: 'wechat:wxid_target', accountId: 'wechat-bot', }, enabled: true, }); const rpc = vi.fn().mockResolvedValue({ id: 'job-wechat', name: 'WeChat delivery', enabled: true, createdAtMs: 1, updatedAtMs: 2, schedule: { kind: 'cron', expr: '0 10 * * *' }, payload: { kind: 'agentTurn', message: 'Send update' }, delivery: { mode: 'announce', channel: 'openclaw-weixin', to: 'wechat:wxid_target', accountId: 'wechat-bot' }, state: {}, }); const { handleCronRoutes } = await import('@electron/api/routes/cron'); const handled = await handleCronRoutes( { method: 'POST' } as IncomingMessage, {} as ServerResponse, new URL('http://127.0.0.1:13210/api/cron/jobs'), { gatewayManager: { rpc }, } as never, ); expect(handled).toBe(true); expect(rpc).toHaveBeenCalledWith('cron.add', expect.objectContaining({ delivery: expect.objectContaining({ mode: 'announce', to: 'wechat:wxid_target' }), })); expect(sendJsonMock).toHaveBeenCalledWith( expect.anything(), 200, expect.objectContaining({ id: 'job-wechat', }), ); }); it('builds failed cron run fallback messages as visible assistant messages', async () => { const { buildCronSessionFallbackMessages } = await import('@electron/api/routes/cron'); const messages = buildCronSessionFallbackMessages({ sessionKey: 'agent:main:cron:job-failed', runs: [{ jobId: 'job-failed', status: 'error', error: '模型超时', ts: 1773281732751, }], limit: 20, }); expect(messages).toContainEqual(expect.objectContaining({ id: 'cron-run-1773281732751', role: 'assistant', content: 'Run failed: 模型超时', isError: true, })); }); it('builds cron fallback query and running messages as visible chat messages', async () => { const { buildCronSessionFallbackMessages } = await import('@electron/api/routes/cron'); const { appendBusinessResponseGuidance } = await import('../../shared/business-guidance'); const messages = buildCronSessionFallbackMessages({ sessionKey: 'agent:main:cron:job-running', job: { name: '写日报', payload: { kind: 'agentTurn', message: appendBusinessResponseGuidance('生成今天的经营日报') }, state: { runningAtMs: 1778650080011 }, }, runs: [], limit: 20, }); expect(messages).toContainEqual(expect.objectContaining({ id: 'cron-query-job-running-1778650080011', role: 'user', content: '生成今天的经营日报', })); expect(messages).toContainEqual(expect.objectContaining({ id: 'cron-running-job-running', role: 'assistant', content: '任务正在执行,完成后会自动在这里显示结果。', })); }); it('reconciles a failed cron state when the run trajectory finished successfully', async () => { const rpc = vi.fn().mockResolvedValue({ jobs: [{ id: 'job-recovered', name: 'Recovered job', enabled: true, createdAtMs: 1, updatedAtMs: 2, schedule: { kind: 'cron', expr: '0 9 * * *' }, payload: { kind: 'agentTurn', message: 'Create a document' }, delivery: { mode: 'none' }, agentId: 'main', state: { lastRunAtMs: 1778650080011, lastStatus: 'error', lastError: 'Edit failed', lastDurationMs: 77388, }, }], }); readFileMock.mockImplementation(async (path: unknown) => { const textPath = String(path); if (textPath.endsWith('/cron/runs/job-recovered.jsonl')) { return JSON.stringify({ ts: 1778650157404, jobId: 'job-recovered', action: 'finished', status: 'error', error: 'Edit failed', summary: 'Edit failed', sessionId: 'run-1', runAtMs: 1778650080011, }); } if (textPath.endsWith('/agents/main/sessions/run-1.trajectory.jsonl')) { return JSON.stringify({ type: 'trace.artifacts', data: { finalStatus: 'success', assistantTexts: ['Done. Saved to the desktop.'], }, }); } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); const { handleCronRoutes } = await import('@electron/api/routes/cron'); const handled = await handleCronRoutes( { method: 'GET' } as IncomingMessage, {} as ServerResponse, new URL('http://127.0.0.1:13210/api/cron/jobs'), { gatewayManager: { rpc }, } as never, ); expect(handled).toBe(true); expect(sendJsonMock).toHaveBeenCalledWith( expect.anything(), 200, [expect.objectContaining({ id: 'job-recovered', lastRun: expect.objectContaining({ success: true, reconciled: true, warning: 'Edit failed', }), })], ); const response = sendJsonMock.mock.calls.at(-1)?.[2] as Array<{ lastRun?: { error?: string } }>; expect(response[0]?.lastRun?.error).toBeUndefined(); }); it('returns a successful trigger response when cron.run reports a stale tool error after recovery', async () => { parseJsonBodyMock.mockResolvedValue({ id: 'job-recovered' }); const rpc = vi.fn() .mockRejectedValueOnce(new Error('Edit failed')) .mockResolvedValueOnce({ jobs: [{ id: 'job-recovered', name: 'Recovered job', enabled: true, createdAtMs: 1, updatedAtMs: 2, schedule: { kind: 'cron', expr: '0 9 * * *' }, payload: { kind: 'agentTurn', message: 'Create a document' }, delivery: { mode: 'none' }, agentId: 'main', state: { lastRunAtMs: 1778650080011, lastStatus: 'error', lastError: 'Edit failed', lastDurationMs: 77388, }, }], }); readFileMock.mockImplementation(async (path: unknown) => { const textPath = String(path); if (textPath.endsWith('/cron/runs/job-recovered.jsonl')) { return JSON.stringify({ ts: 1778650157404, jobId: 'job-recovered', action: 'finished', status: 'error', error: 'Edit failed', summary: 'Edit failed', sessionId: 'run-1', runAtMs: 1778650080011, }); } if (textPath.endsWith('/agents/main/sessions/run-1.trajectory.jsonl')) { return JSON.stringify({ type: 'trace.artifacts', data: { finalStatus: 'success', assistantTexts: ['Done. Saved to the desktop.'], }, }); } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); const { handleCronRoutes } = await import('@electron/api/routes/cron'); const handled = await handleCronRoutes( { method: 'POST' } as IncomingMessage, {} as ServerResponse, new URL('http://127.0.0.1:13210/api/cron/trigger'), { gatewayManager: { rpc }, } as never, ); expect(handled).toBe(true); expect(rpc).toHaveBeenNthCalledWith(1, 'cron.run', { id: 'job-recovered', mode: 'force' }); expect(rpc).toHaveBeenNthCalledWith(2, 'cron.list', { includeDisabled: true }, 8000); expect(sendJsonMock).toHaveBeenCalledWith( expect.anything(), 200, expect.objectContaining({ success: true, recovered: true, warning: 'Edit failed', summary: 'Done. Saved to the desktop.', triggerWarning: 'Error: Edit failed', }), ); }); it('uses recovered trajectory summaries for cron fallback messages', async () => { const { buildCronSessionFallbackMessages } = await import('@electron/api/routes/cron'); const messages = buildCronSessionFallbackMessages({ sessionKey: 'agent:main:cron:job-recovered', runs: [{ jobId: 'job-recovered', status: 'error', resolvedStatus: 'ok', error: 'Edit failed', resolvedSummary: 'Done. Saved to the desktop.', ts: 1778650157404, }], limit: 20, }); expect(messages).toContainEqual(expect.objectContaining({ id: 'cron-run-1778650157404', role: 'assistant', content: 'Done. Saved to the desktop.', })); expect(messages.find((message) => message.id === 'cron-run-1778650157404')?.isError).toBeUndefined(); }); });