schedules.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. // 定时任务(docs/design/14-schedules.md):时间表计算;在对话中创建(确认卡片)、到点执行、跳过、成本超限暂停;工作组中的定时任务
  2. import pino from 'pino'
  3. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  4. import type { InteractionRequest, ProviderStatusDto, ScheduleDto, TeamDetailDto, TeamEvent, TeamMessage } from '@agent-server/shared'
  5. import { createApp, type App } from '../src/app'
  6. import { buildServer } from '../src/http/server'
  7. import type { AgentProvider, RunContext } from '../src/providers/types'
  8. import { describeSpec, nextRun, parseSpec } from '../src/schedules/spec'
  9. type Call = (tool: string, args: Record<string, unknown>) => Promise<{ text: string; isError: boolean }>
  10. /** 假引擎:输入中带"【定时任务"时按 onScheduled 执行,否则按 script 执行(可以调用网关工具) */
  11. class ScriptedProvider implements AgentProvider {
  12. readonly id = 'claude' as const
  13. script: (input: string, call: Call) => Promise<string> = async () => 'ok'
  14. inputs: { sessionId: string; text: string }[] = []
  15. cost = 0.01
  16. /** 为 true 时一直执行到被中断 */
  17. hang = false
  18. async run(ctx: RunContext) {
  19. ctx.setProviderSessionId(`fake-${ctx.sessionId}`)
  20. this.inputs.push({ sessionId: ctx.sessionId, text: ctx.input.text })
  21. const gctx = app.gatewayTokens.get(ctx.gateway.token)!
  22. const call: Call = async (tool, args) => {
  23. const t = gctx.extraTools?.find(x => x.name === tool)
  24. if (!t) return { text: `没有工具 ${tool}`, isError: true }
  25. const r = await t.handler(t.schema.parse(args) as Record<string, unknown>, gctx)
  26. return { text: String(r.content[0]?.text ?? ''), isError: !!r.isError }
  27. }
  28. if (this.hang) {
  29. await new Promise((_, reject) => ctx.signal.addEventListener('abort', () => reject(new Error('aborted'))))
  30. }
  31. const reply = await this.script(ctx.input.text, call)
  32. ctx.emit('message.completed', { messageId: `m${this.inputs.length}`, text: reply })
  33. return { usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: this.cost } }
  34. }
  35. async status(): Promise<ProviderStatusDto> {
  36. return { id: 'claude', enabled: true, available: true, loggedIn: true, account: 'test', cliPath: '', version: 'x', models: ['m'], defaultModel: 'm', message: null }
  37. }
  38. }
  39. let app: App
  40. let server: Awaited<ReturnType<typeof buildServer>>
  41. const fake = new ScriptedProvider()
  42. let apiKey: string
  43. let otherKey: string
  44. let botKey: string
  45. const auth = (key = apiKey) => ({ authorization: `Bearer ${key}` })
  46. async function waitFor<T>(fn: () => T | undefined | false | null, ms = 4000): Promise<T> {
  47. const end = Date.now() + ms
  48. while (Date.now() < end) {
  49. const v = fn()
  50. if (v) return v
  51. await new Promise(r => setTimeout(r, 20))
  52. }
  53. throw new Error('timeout')
  54. }
  55. const DAILY = { kind: 'daily', time: '09:00' }
  56. const later = (hours: number) => Date.now() + hours * 3_600_000
  57. beforeAll(async () => {
  58. const log = pino({ level: 'silent' })
  59. app = createApp(log, { providers: { claude: fake } })
  60. server = await buildServer(app, { logger: log })
  61. const builtin = app.profiles.getBuiltin().id
  62. apiKey = app.apiKeys.create('平台A', { allowedProfileIds: [builtin], defaultProfileId: builtin }).key
  63. otherKey = app.apiKeys.create('平台B', { allowedProfileIds: [builtin], defaultProfileId: builtin }).key
  64. const bot = app.bots.create({ name: '日报员', avatar: 'kl:bot:blue', kind: 'worker', description: '写日报', profileId: builtin, enabled: true })
  65. botKey = app.botKeys.create('平台A', [bot.id]).key
  66. })
  67. afterAll(() => {
  68. app.schedules.stop()
  69. return server.close()
  70. })
  71. describe('时间表', () => {
  72. const tz = 'Asia/Shanghai'
  73. // 2026-10-27 是周二;北京时间 10:00 = UTC 02:00
  74. const now = Date.parse('2026-10-27T02:00:00Z')
  75. const at = (ms: number | null) => (ms === null ? null : new Date(ms).toISOString())
  76. it('按时区计算下次执行时间', () => {
  77. expect(at(nextRun({ kind: 'daily', time: '09:00' }, tz, now))).toBe('2026-10-28T01:00:00.000Z')
  78. expect(at(nextRun({ kind: 'daily', time: '11:30' }, tz, now))).toBe('2026-10-27T03:30:00.000Z')
  79. // 周五之后的工作日是下周一
  80. expect(at(nextRun({ kind: 'weekdays', time: '09:00' }, tz, Date.parse('2026-10-30T02:00:00Z')))).toBe('2026-11-02T01:00:00.000Z')
  81. expect(at(nextRun({ kind: 'weekly', days: [1, 5], time: '09:00' }, tz, now))).toBe('2026-10-30T01:00:00.000Z')
  82. // 每月 31 日:11 月没有 31 日,取 30 日
  83. expect(at(nextRun({ kind: 'monthly', day: 31, time: '09:00' }, tz, Date.parse('2026-11-01T00:00:00Z')))).toBe('2026-11-30T01:00:00.000Z')
  84. expect(at(nextRun({ kind: 'interval', hours: 2 }, tz, now))).toBe('2026-10-27T04:00:00.000Z')
  85. expect(at(nextRun({ kind: 'once', at: '2026-10-27 18:00' }, tz, now))).toBe('2026-10-27T10:00:00.000Z')
  86. expect(nextRun({ kind: 'once', at: '2026-10-27 08:00' }, tz, now)).toBeNull()
  87. // 其他时区
  88. expect(at(nextRun({ kind: 'daily', time: '09:00' }, 'America/New_York', now))).toBe('2026-10-27T13:00:00.000Z')
  89. })
  90. it('校验与描述', () => {
  91. const opts = { minIntervalHours: 1, timezone: tz, now }
  92. expect(parseSpec({ kind: 'daily', time: '9:05' }, opts)).toEqual({ kind: 'daily', time: '09:05' })
  93. // 相对时间:北京时间 10:00 的 90 分钟后
  94. expect(parseSpec({ kind: 'once', after_minutes: 90 }, opts)).toEqual({ kind: 'once', at: '2026-10-27 11:30' })
  95. expect(() => parseSpec({ kind: 'daily', time: '25:00' }, opts)).toThrow('HH:mm')
  96. expect(() => parseSpec({ kind: 'interval', hours: 0.5 }, opts)).toThrow('不能小于 1')
  97. expect(() => parseSpec({ kind: 'once', at: '2026-10-27 08:00' }, opts)).toThrow('已经过去')
  98. expect(() => parseSpec({ kind: 'once', at: '2026-02-30 08:00' }, { ...opts, now: Date.parse('2026-01-01T00:00:00Z') })).toThrow('不存在')
  99. expect(() => parseSpec({ kind: 'weekly', days: [], time: '09:00' }, opts)).toThrow('days')
  100. expect(describeSpec({ kind: 'weekly', days: [1, 3, 5], time: '09:00' })).toBe('每周一、三、五 09:00')
  101. expect(describeSpec({ kind: 'weekdays', time: '09:00' })).toBe('每个工作日(周一到周五)09:00')
  102. })
  103. })
  104. describe('会话中的定时任务', () => {
  105. let sessionId: string
  106. let created: { text: string; isError: boolean } | undefined
  107. async function approveNext(decision: 'allow_once' | 'deny', headers = auth()) {
  108. const itx = await waitFor(() => app.interactions.pendingFor(sessionId)[0]) as InteractionRequest
  109. expect(itx.kind === 'approval' && itx.tool).toBe('schedule_create')
  110. expect(itx.kind === 'approval' && itx.detail?.command).toContain('接下来:')
  111. const r = await server.inject({ method: 'POST', url: `/v1/interactions/${itx.interactionId}`, headers, payload: { decision } })
  112. expect(r.statusCode).toBe(200)
  113. }
  114. it('AI 创建定时任务时显示确认卡片,拒绝时不创建,确认后生效', async () => {
  115. sessionId = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} })).json().id
  116. fake.script = async (_input, call) => {
  117. created = await call('schedule_create', { title: '销售日报', prompt: '汇总前一天的销售数据,写到 outputs/daily.md', schedule: DAILY })
  118. return '好的'
  119. }
  120. await server.inject({ method: 'POST', url: `/v1/sessions/${sessionId}/messages`, headers: auth(), payload: { text: '每天 9 点帮我出日报' } })
  121. await approveNext('deny')
  122. await waitFor(() => created)
  123. expect(created!.text).toContain('没有确认')
  124. expect(app.schedules.list({ kind: 'session', sessionId })).toHaveLength(0)
  125. created = undefined
  126. await waitFor(() => !app.runs.activeForSession(sessionId))
  127. await server.inject({ method: 'POST', url: `/v1/sessions/${sessionId}/messages`, headers: auth(), payload: { text: '确认,每天 9 点' } })
  128. await approveNext('allow_once')
  129. await waitFor(() => created)
  130. expect(created!.text).toContain('已创建定时任务')
  131. const list = (await server.inject({ method: 'GET', url: `/v1/sessions/${sessionId}/schedules`, headers: auth() })).json() as ScheduleDto[]
  132. expect(list).toHaveLength(1)
  133. expect(list[0]).toMatchObject({ title: '销售日报', description: '每天 09:00', status: 'active', timezone: 'Asia/Shanghai', target: { kind: 'session', sessionId } })
  134. expect(list[0]!.nextRunAt).toBeTruthy()
  135. // 其他平台看不到、改不了
  136. expect((await server.inject({ method: 'GET', url: `/v1/sessions/${sessionId}/schedules`, headers: auth(otherKey) })).statusCode).toBe(404)
  137. expect((await server.inject({ method: 'DELETE', url: `/v1/schedules/${list[0]!.id}`, headers: auth(otherKey) })).statusCode).toBe(404)
  138. })
  139. it('到点时往同一个会话发出指令;会话正在执行时跳过;成本超出上限时自动暂停', async () => {
  140. await waitFor(() => !app.runs.activeForSession(sessionId))
  141. const [s] = app.schedules.list({ kind: 'session', sessionId })
  142. fake.script = async () => '日报已生成'
  143. const before = fake.inputs.length
  144. await app.schedules.tick(later(25))
  145. const input = await waitFor(() => fake.inputs.length > before && fake.inputs.at(-1))
  146. expect(input.sessionId).toBe(sessionId)
  147. expect(input.text).toMatch(/^【定时任务「销售日报」· .+】\n汇总前一天的销售数据/)
  148. await waitFor(() => app.schedules.runs(s!.id)[0]?.outcome === 'completed')
  149. expect(app.schedules.runs(s!.id)[0]).toMatchObject({ outcome: 'completed', costUsd: 0.01 })
  150. const after = app.schedules.list({ kind: 'session', sessionId })[0]!
  151. expect(after).toMatchObject({ runCount: 1, lastOutcome: 'completed', status: 'active' })
  152. expect(Date.parse(after.nextRunAt!)).toBeGreaterThan(Date.now())
  153. // 会话正在执行时跳过
  154. fake.hang = true
  155. await server.inject({ method: 'POST', url: `/v1/sessions/${sessionId}/messages`, headers: auth(), payload: { text: '做别的' } })
  156. await waitFor(() => app.runs.activeForSession(sessionId))
  157. await app.schedules.tick(later(50))
  158. expect(app.schedules.runs(s!.id)[0]).toMatchObject({ outcome: 'skipped', note: '会话正在执行,跳过本次' })
  159. app.runs.interrupt(app.runs.activeForSession(sessionId)!.runId, sessionId)
  160. fake.hang = false
  161. await waitFor(() => !app.runs.activeForSession(sessionId))
  162. // 成本超出上限:执行完成后暂停
  163. app.settings.update({ schedules: { maxCostUsdPerRun: 0.05 } })
  164. fake.cost = 0.2
  165. await app.schedules.tick(later(75))
  166. const paused = await waitFor(() => app.schedules.list({ kind: 'session', sessionId }).find(x => x.status === 'paused'))
  167. expect(paused.statusNote).toContain('超过每次的上限 $0.05')
  168. expect(paused.nextRunAt).toBeNull()
  169. fake.cost = 0.01
  170. // 平台可以恢复、删除
  171. const resumed = await server.inject({ method: 'PATCH', url: `/v1/schedules/${s!.id}`, headers: auth(), payload: { status: 'active' } })
  172. expect(resumed.json()).toMatchObject({ status: 'active', statusNote: null })
  173. const runsRes = (await server.inject({ method: 'GET', url: `/v1/schedules/${s!.id}/runs`, headers: auth() })).json()
  174. expect(runsRes.map((r: { outcome: string }) => r.outcome)).toEqual(['completed', 'skipped', 'completed'])
  175. expect((await server.inject({ method: 'DELETE', url: `/v1/schedules/${s!.id}`, headers: auth() })).statusCode).toBe(204)
  176. expect(app.schedules.list({ kind: 'session', sessionId })).toHaveLength(0)
  177. })
  178. it('AI 可以列出、暂停和删除;删除会话时定时任务一起删除', async () => {
  179. await waitFor(() => !app.runs.activeForSession(sessionId))
  180. const results: Record<string, string> = {}
  181. fake.script = async (input, call) => {
  182. if (input.includes('建一个')) results.create = (await call('schedule_create', { title: '周报', prompt: '写周报', schedule: { kind: 'weekly', days: [1], time: '10:00' } })).text
  183. if (input.includes('暂停')) {
  184. const id = app.schedules.list({ kind: 'session', sessionId })[0]!.id
  185. results.pause = (await call('schedule_update', { schedule_id: id, status: 'paused' })).text
  186. results.list = (await call('schedule_list', {})).text
  187. }
  188. return 'ok'
  189. }
  190. await server.inject({ method: 'POST', url: `/v1/sessions/${sessionId}/messages`, headers: auth(), payload: { text: '建一个周报' } })
  191. await approveNext('allow_once')
  192. await waitFor(() => results.create && !app.runs.activeForSession(sessionId))
  193. await server.inject({ method: 'POST', url: `/v1/sessions/${sessionId}/messages`, headers: auth(), payload: { text: '暂停周报' } })
  194. await waitFor(() => results.list)
  195. // 暂停不需要确认
  196. expect(results.pause).toContain('已暂停')
  197. expect(results.list).toContain('每周一 10:00')
  198. await waitFor(() => !app.runs.activeForSession(sessionId))
  199. expect((await server.inject({ method: 'DELETE', url: `/v1/sessions/${sessionId}`, headers: auth() })).statusCode).toBe(204)
  200. expect(app.schedules.overview().filter(x => x.target.kind === 'session' && x.target.sessionId === sessionId)).toHaveLength(0)
  201. })
  202. })
  203. describe('工作组中的定时任务', () => {
  204. it('组员创建的定时任务到点时 @ 它;工作组忙时跳过;后台可以查看全部', async () => {
  205. const bot = app.bots.list()[0]!
  206. const team = (await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: auth(botKey), payload: { human: { id: 'u1', name: '张三' }, workerBotIds: [bot.id] } })).json() as TeamDetailDto
  207. let created: { text: string; isError: boolean } | undefined
  208. fake.script = async (input, call) => {
  209. if (input.includes('每天')) created = await call('schedule_create', { title: '日报', prompt: '写日报', schedule: DAILY })
  210. return input.includes('【定时任务') ? '日报写好了' : '好的'
  211. }
  212. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: auth(botKey), payload: { text: '每天 9 点写日报' } })
  213. const pending = await waitFor(() => app.teams.detail(app.teams.getRow(team.id)).pendingInteractions[0])
  214. expect(pending.request.kind === 'approval' && pending.request.tool).toBe('schedule_create')
  215. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/interactions/${pending.request.interactionId}`, headers: auth(botKey), payload: { decision: 'allow_once' } })
  216. await waitFor(() => created)
  217. const list = (await server.inject({ method: 'GET', url: `/bot/v1/teams/${team.id}/schedules`, headers: auth(botKey) })).json() as ScheduleDto[]
  218. expect(list[0]).toMatchObject({ title: '日报', target: { kind: 'team', teamId: team.id }, createdByBotId: bot.id })
  219. await waitFor(() => app.teams.toDto(app.teams.getRow(team.id)).bots.every(b => b.status === 'idle'))
  220. const messages = () => app.teams.events.list(team.id, 0, 5000, ['message.created']).map(e => (e as TeamEvent & { type: 'message.created' }).payload as TeamMessage)
  221. await app.schedules.tick(later(25))
  222. const sent = messages().find(m => m.text.startsWith('【定时任务「日报」'))!
  223. expect(sent).toMatchObject({ sender: { kind: 'system' }, mentions: [bot.id], targets: [bot.id] })
  224. await waitFor(() => messages().some(m => m.text === '日报写好了'))
  225. expect(app.schedules.runs(list[0]!.id)[0]!.outcome).toBe('sent')
  226. // 机器人在工作时跳过
  227. fake.hang = true
  228. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: auth(botKey), payload: { text: '先做别的' } })
  229. await waitFor(() => app.teams.isBusy(team.id))
  230. await app.schedules.tick(later(50))
  231. expect(app.schedules.runs(list[0]!.id)[0]!.outcome).toBe('skipped')
  232. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/bots/${bot.id}/interrupt`, headers: auth(botKey) })
  233. fake.hang = false
  234. const overview = (await server.inject({ method: 'GET', url: '/admin/v1/schedules', cookies: await adminCookie() })).json()
  235. expect(overview.find((x: { id: string }) => x.id === list[0]!.id)).toMatchObject({ targetTitle: team.title, source: expect.any(String) })
  236. expect((await server.inject({ method: 'DELETE', url: `/bot/v1/teams/${team.id}/schedules/${list[0]!.id}`, headers: auth(botKey) })).statusCode).toBe(204)
  237. })
  238. })
  239. async function adminCookie() {
  240. const login = await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'test-password' } })
  241. const c = login.cookies[0]!
  242. return { [c.name]: c.value }
  243. }