@zhushanwen/pi-scheduler 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,232 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ // M11:mock store 模块,避免 SchedulerRuntime 触发真实 FS 写入。
4
+ // 原实现用 new SchedulerRuntime('/test', ...) → createStore('/test') 会写
5
+ // ~/.pi/agent/scheduler/root/test/scheduler.json(store.test.ts:87 stderr 已证实)。
6
+ // mock 后 load 返回空 store、persist/persistSync 为 no-op,runtime 完全不碰 FS。
7
+ vi.mock('../store.js', () => ({
8
+ createStore: () => ({
9
+ load: () => ({ version: 1, tasks: [] }),
10
+ persist: vi.fn(),
11
+ persistSync: vi.fn(),
12
+ storePath: '/mocked/scheduler.json',
13
+ }),
14
+ }))
15
+
16
+ import { SchedulerRuntime } from '../runtime.js'
17
+
18
+ // Mock pi 和 ctx
19
+ const mockPi = { sendMessage: vi.fn() }
20
+ const mockCtx = { isIdle: () => true, hasPendingMessages: () => false }
21
+
22
+ describe('SchedulerRuntime', () => {
23
+ let runtime: SchedulerRuntime
24
+
25
+ beforeEach(() => {
26
+ vi.clearAllMocks()
27
+ runtime = new SchedulerRuntime('/test', mockPi as never, mockCtx as never)
28
+ })
29
+
30
+ describe('addTask', () => {
31
+ it('creates a new task', async () => {
32
+ const task = await runtime.addTask('check build', { mode: 'interval', intervalMs: 60000 })
33
+ expect(task.id).toHaveLength(8)
34
+ expect(task.prompt).toBe('check build')
35
+ expect(task.enabled).toBe(true)
36
+ })
37
+
38
+ it('throws when task limit reached', async () => {
39
+ for (let i = 0; i < 50; i++) {
40
+ await runtime.addTask(`task ${i}`, { mode: 'interval', intervalMs: 60000 })
41
+ }
42
+ await expect(runtime.addTask('one more', { mode: 'interval', intervalMs: 60000 }))
43
+ .rejects.toThrow('Task limit reached')
44
+ })
45
+ })
46
+
47
+ describe('listTasks', () => {
48
+ it('returns tasks sorted by nextRunAt', async () => {
49
+ await runtime.addTask('task 1', { mode: 'interval', intervalMs: 60000 })
50
+ await runtime.addTask('task 2', { mode: 'interval', intervalMs: 30000 })
51
+ const tasks = runtime.listTasks()
52
+ expect(tasks).toHaveLength(2)
53
+ // 30s interval 的 nextRunAt 早于 60s 的,应排前
54
+ expect(tasks[0]!.nextRunAt).toBeLessThan(tasks[1]!.nextRunAt)
55
+ })
56
+
57
+ // 强化断言:30s 任务 nextRunAt 更小(更早),应是 listTasks()[0]
58
+ it('orders shorter-interval task first', async () => {
59
+ const t60 = await runtime.addTask('60s', { mode: 'interval', intervalMs: 60000 })
60
+ const t30 = await runtime.addTask('30s', { mode: 'interval', intervalMs: 30000 })
61
+ const tasks = runtime.listTasks()
62
+ expect(tasks[0]!.id).toBe(t30.id)
63
+ expect(tasks[0]!.nextRunAt).toBeLessThan(t60.nextRunAt)
64
+ })
65
+ })
66
+
67
+ describe('toggleTask', () => {
68
+ it('toggles task enabled state', async () => {
69
+ const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
70
+ expect(await runtime.toggleTask(task.id, false)).toBe(true)
71
+ expect(runtime.getTask(task.id)?.enabled).toBe(false)
72
+ })
73
+
74
+ it('returns false for non-existent task', async () => {
75
+ expect(await runtime.toggleTask('nonexistent', true)).toBe(false)
76
+ })
77
+ })
78
+
79
+ describe('deleteTask', () => {
80
+ it('deletes existing task', async () => {
81
+ const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
82
+ expect(runtime.deleteTask(task.id)).toBe(true)
83
+ expect(runtime.getTask(task.id)).toBeUndefined()
84
+ })
85
+
86
+ it('returns false for non-existent task', () => {
87
+ expect(runtime.deleteTask('nonexistent')).toBe(false)
88
+ })
89
+ })
90
+
91
+ describe('dispatchTask', () => {
92
+ it('dispatches task when idle', async () => {
93
+ const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
94
+ runtime.dispatchTask(task)
95
+ expect(mockPi.sendMessage).toHaveBeenCalledWith(
96
+ expect.objectContaining({ content: 'test' }),
97
+ expect.any(Object),
98
+ )
99
+ })
100
+
101
+ it('skips disabled task', async () => {
102
+ const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
103
+ await runtime.toggleTask(task.id, false)
104
+ runtime.dispatchTask(task)
105
+ expect(mockPi.sendMessage).not.toHaveBeenCalled()
106
+ })
107
+
108
+ it('skips when not idle and force is false', async () => {
109
+ const busyCtx = { isIdle: () => false, hasPendingMessages: () => false }
110
+ const busyRuntime = new SchedulerRuntime('/test', mockPi as never, busyCtx as never)
111
+ const task = await busyRuntime.addTask('test', { mode: 'interval', intervalMs: 60000 })
112
+ busyRuntime.dispatchTask(task)
113
+ expect(mockPi.sendMessage).not.toHaveBeenCalled()
114
+ })
115
+
116
+ it('dispatches when force is true even if busy', async () => {
117
+ const busyCtx = { isIdle: () => false, hasPendingMessages: () => false }
118
+ const busyRuntime = new SchedulerRuntime('/test', mockPi as never, busyCtx as never)
119
+ const task = await busyRuntime.addTask('test', { mode: 'interval', intervalMs: 60000 }, { force: true })
120
+ busyRuntime.dispatchTask(task)
121
+ expect(mockPi.sendMessage).toHaveBeenCalled()
122
+ })
123
+
124
+ // OR 组合补全:源码 `!isIdle() || hasPendingMessages()` 任一为真即跳过。
125
+ // idle=true 但有 pending message → dispatch 应被跳过。
126
+ it('skips when idle but has pending messages', async () => {
127
+ const pendingCtx = { isIdle: () => true, hasPendingMessages: () => true }
128
+ const pendingRuntime = new SchedulerRuntime('/test', mockPi as never, pendingCtx as never)
129
+ const task = await pendingRuntime.addTask('test', { mode: 'interval', intervalMs: 60000 })
130
+ pendingRuntime.dispatchTask(task)
131
+ expect(mockPi.sendMessage).not.toHaveBeenCalled()
132
+ })
133
+ })
134
+
135
+ // ── M10b:rate-limit ──
136
+ // dispatchTask 受 RATE_LIMIT_PER_MINUTE=6 限制。前 6 次成功(sendMessage 被调),
137
+ // 第 7 次被 hasDispatchCapacity 拒绝(dispatchTimestamps.length 已达 6)。
138
+ describe('rate-limit', () => {
139
+ beforeEach(() => {
140
+ vi.useFakeTimers()
141
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
142
+ })
143
+
144
+ it('rate-limits dispatch to 6 per minute', async () => {
145
+ // force=true 保证不被 idle/busy 干扰,直接命中 rate-limit
146
+ const tasks: Awaited<ReturnType<typeof runtime.addTask>>[] = []
147
+ for (let i = 0; i < 7; i++) {
148
+ tasks.push(await runtime.addTask(`task ${i}`, { mode: 'interval', intervalMs: 60000 }, { force: true }))
149
+ }
150
+
151
+ // 7 次 dispatch 全在同一分钟内(fake time 不前进)
152
+ for (const task of tasks) {
153
+ await runtime.dispatchTask(task)
154
+ }
155
+
156
+ // 前 6 次成功,第 7 次被限流:sendMessage 只被调 6 次
157
+ expect(mockPi.sendMessage).toHaveBeenCalledTimes(6)
158
+ })
159
+
160
+ it('allows dispatch again after 1 minute window slides', async () => {
161
+ const task = await runtime.addTask('t', { mode: 'interval', intervalMs: 60000 }, { force: true })
162
+ // 先消耗完 6 次配额
163
+ for (let i = 0; i < 6; i++) {
164
+ // 同一 task 反复 dispatch(interval 模式每次重算 nextRunAt,不影响 rate-limit 计数)
165
+ await runtime.dispatchTask(task)
166
+ }
167
+ expect(mockPi.sendMessage).toHaveBeenCalledTimes(6)
168
+
169
+ // 时间前进 61 秒:旧 timestamp 滑出窗口,配额恢复
170
+ vi.setSystemTime(new Date('2026-01-01T00:01:01Z'))
171
+ const dispatched = await runtime.dispatchTask(task)
172
+ expect(dispatched).toBe(true)
173
+ expect(mockPi.sendMessage).toHaveBeenCalledTimes(7)
174
+ })
175
+ })
176
+
177
+ // ── M10d:tickScheduler ──
178
+ describe('tickScheduler', () => {
179
+ afterEach(() => {
180
+ vi.useRealTimers()
181
+ })
182
+
183
+ it('dispatches due interval tasks and advances nextRunAt', async () => {
184
+ vi.useFakeTimers()
185
+ const start = new Date('2026-01-01T00:00:00Z')
186
+ vi.setSystemTime(start)
187
+
188
+ const task = await runtime.addTask('tick me', { mode: 'interval', intervalMs: 60000 })
189
+ // 手动让任务过期(nextRunAt 设为过去)
190
+ task.nextRunAt = Date.now() - 1000
191
+
192
+ await runtime.tickScheduler()
193
+
194
+ // 已 dispatch
195
+ expect(mockPi.sendMessage).toHaveBeenCalledTimes(1)
196
+ const updated = runtime.getTask(task.id)
197
+ expect(updated).toBeDefined()
198
+ expect(updated!.runCount).toBe(1)
199
+ // nextRunAt 推进到 now + intervalMs(60000ms)
200
+ expect(updated!.nextRunAt).toBe(Date.now() + 60000)
201
+ })
202
+
203
+ it('removes expired tasks (expiresAt in the past)', async () => {
204
+ vi.useFakeTimers()
205
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
206
+
207
+ const task = await runtime.addTask('expire me', { mode: 'interval', intervalMs: 60000 })
208
+ // expiresAt 已过:tick 的第 1 步清理会删除
209
+ task.expiresAt = Date.now() - 1000
210
+
211
+ await runtime.tickScheduler()
212
+
213
+ expect(runtime.getTask(task.id)).toBeUndefined()
214
+ // 过期清理先于 dispatch,不应 dispatch
215
+ expect(mockPi.sendMessage).not.toHaveBeenCalled()
216
+ })
217
+
218
+ it('deletes once task after dispatch', async () => {
219
+ vi.useFakeTimers()
220
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
221
+
222
+ const task = await runtime.addTask('one-shot', { mode: 'interval', intervalMs: 60000 }, { kind: 'once' })
223
+ task.nextRunAt = Date.now() - 1000
224
+
225
+ await runtime.tickScheduler()
226
+
227
+ // once 任务 dispatch 后自删
228
+ expect(runtime.getTask(task.id)).toBeUndefined()
229
+ expect(mockPi.sendMessage).toHaveBeenCalledTimes(1)
230
+ })
231
+ })
232
+ })
@@ -0,0 +1,178 @@
1
+ // src/__tests__/sdk-contract.test.ts
2
+ //
3
+ // SDK 契约测试:验证 pi-scheduler 扩展对 Pi SDK 的消费符合契约。
4
+ // 保护本次 PR 修复的 4 个 tool 注册 bug 不回归:
5
+ // 1. tool 注册名为 schedule / schedule_control
6
+ // 2. tool 有 execute 函数字段(不是 handler/fn)
7
+ // 3. execute 是 async function(SDK 期望返回 Promise)
8
+ // 4. handler 抛错被 catch 转为 { isError: true }(standards.md §4.2 禁止抛)
9
+ //
10
+ // 不导入 SchedulerRuntime 的内部:只通过 index.ts 的 default export 测,
11
+ // 保证 tool 注册逻辑的入口契约。
12
+ //
13
+ // 关键回归点:runtime 在 session_start 前为 null。execute 通过 getRuntime() 延迟
14
+ // 读取——若在 factory 顶层捕获 runtime! 非空断言,注册时 runtime 为 null,
15
+ // execute 调用会 NPE。此套件验证 session_start 前 execute 优雅返回 isError 而非 crash。
16
+
17
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
18
+ import { describe, expect, it, vi } from 'vitest'
19
+
20
+ // Mock store 模块:避免 runtime 触发真实 FS 写入(session_start 会创建 SchedulerRuntime,
21
+ // 进而 createStore(cwd) 写 ~/.pi/agent/scheduler/...)。sdk-contract 只验证注册契约,
22
+ // 不关心持久化,故 store 返回空状态 + no-op persist。
23
+ vi.mock('../store.js', () => ({
24
+ createStore: () => ({
25
+ load: () => ({ version: 1, tasks: [] }),
26
+ persist: vi.fn(),
27
+ persistSync: vi.fn(),
28
+ storePath: '/mocked/scheduler.json',
29
+ }),
30
+ }))
31
+
32
+ import schedulerExtension from '../index.js'
33
+
34
+ /**
35
+ * 构造 mock pi:捕获 registerTool 收到的 tool definition + registerCommand + 事件 handler。
36
+ * sendMessage 为空 vi.fn(),dispatch 路径会调用它但不影响契约断言。
37
+ */
38
+ /** 捕获到的 tool definition:只关心我们要断言的字段。 */
39
+ interface CapturedTool {
40
+ name: string
41
+ execute: (...args: unknown[]) => Promise<Record<string, unknown>>
42
+ handler?: unknown
43
+ fn?: unknown
44
+ [key: string]: unknown
45
+ }
46
+
47
+ function createMockPi(): {
48
+ pi: ExtensionAPI
49
+ tools: CapturedTool[]
50
+ commands: { name: string; opts: Record<string, unknown> }[]
51
+ events: Map<string, (...args: unknown[]) => void>
52
+ } {
53
+ const tools: CapturedTool[] = []
54
+ const commands: { name: string; opts: Record<string, unknown> }[] = []
55
+ const events = new Map<string, (...args: unknown[]) => void>()
56
+ const pi = {
57
+ registerTool: (tool: CapturedTool) => tools.push(tool),
58
+ registerCommand: (name: string, opts: Record<string, unknown>) => commands.push({ name, opts }),
59
+ on: (event: string, handler: (...args: unknown[]) => void) => events.set(event, handler),
60
+ sendMessage: vi.fn(),
61
+ } as unknown as ExtensionAPI
62
+ return { pi, tools, commands, events }
63
+ }
64
+
65
+ /**
66
+ * 构造最小 fakeCtx:覆盖 index.ts 在 session_start/refreshWidget 中读到的字段。
67
+ * setWidget 在 session_start 立即调用一次(refreshWidget),故必须存在。
68
+ */
69
+ function createFakeCtx(): ExtensionContext {
70
+ return {
71
+ cwd: '/test',
72
+ isIdle: () => true,
73
+ hasPendingMessages: () => false,
74
+ ui: { setWidget: vi.fn() },
75
+ } as unknown as ExtensionContext
76
+ }
77
+
78
+ describe('pi-scheduler SDK contract', () => {
79
+ it('registerTool 被调 2 次,name 为 schedule / schedule_control', () => {
80
+ const { pi, tools } = createMockPi()
81
+ schedulerExtension(pi)
82
+ expect(tools).toHaveLength(2)
83
+ expect(tools.map(t => t.name).sort()).toEqual(['schedule', 'schedule_control'])
84
+ })
85
+
86
+ it('每个 tool 有 execute 函数字段(不是 handler/fn)—— 本 PR 修的核心 bug', () => {
87
+ const { pi, tools } = createMockPi()
88
+ schedulerExtension(pi)
89
+ for (const tool of tools) {
90
+ expect(typeof tool.execute).toBe('function')
91
+ // 反回归:旧的错误字段名不应存在
92
+ expect(tool.handler).toBeUndefined()
93
+ expect(tool.fn).toBeUndefined()
94
+ }
95
+ })
96
+
97
+ it('execute 是 async function', () => {
98
+ const { pi, tools } = createMockPi()
99
+ schedulerExtension(pi)
100
+ for (const tool of tools) {
101
+ // async function 的 constructor 是 AsyncFunction
102
+ expect(tool.execute.constructor.name).toBe('AsyncFunction')
103
+ }
104
+ })
105
+
106
+ it('session_start 前 execute 抛错返回 isError(runtime 未初始化)', async () => {
107
+ const { pi, tools, events } = createMockPi()
108
+ schedulerExtension(pi)
109
+ const fakeCtx = createFakeCtx()
110
+
111
+ // 不触发 session_start,runtime 仍为 null
112
+ expect(events.get('session_start')).toBeDefined()
113
+
114
+ const result = await tools[0]!.execute('call-1', { prompt: 'x', schedule: '5m' }, undefined, undefined, fakeCtx)
115
+ expect(result).toEqual({
116
+ content: [{ type: 'text', text: 'Error: Scheduler not initialized: session not started' }],
117
+ details: {},
118
+ isError: true,
119
+ })
120
+ })
121
+
122
+ it('session_start 后 execute 正常返回结果', async () => {
123
+ const { pi, tools, events } = createMockPi()
124
+ schedulerExtension(pi)
125
+ const fakeCtx = createFakeCtx()
126
+
127
+ // 触发 session_start:runtime 被创建、loadTasks、startScheduler、refreshWidget
128
+ const sessionStart = events.get('session_start')!
129
+ await sessionStart({ type: 'session_start', reason: 'startup' }, fakeCtx)
130
+
131
+ const result = await tools[0]!.execute('call-2', { prompt: 'check build', schedule: '5m' }, undefined, undefined, fakeCtx)
132
+ // 正常路径:返回 content(非 isError)
133
+ expect(result.isError).toBeFalsy()
134
+ expect(result.content[0].text).toContain('Task "check build"')
135
+ })
136
+
137
+ it('execute 签名兼容 SDK 全签名(5 参数:toolCallId, params, signal, onUpdate, ctx)', async () => {
138
+ const { pi, tools, events } = createMockPi()
139
+ schedulerExtension(pi)
140
+ const fakeCtx = createFakeCtx()
141
+ await events.get('session_start')!({ type: 'session_start', reason: 'startup' }, fakeCtx)
142
+
143
+ // 传全 5 参,signal/onUpdate 为 undefined,不应抛
144
+ const result = await tools[0]!.execute('call-full', { prompt: 'x', schedule: '1h' }, undefined, undefined, fakeCtx)
145
+ expect(result.isError).toBeFalsy()
146
+ })
147
+
148
+ it('handler 抛错被 catch 为 isError(而非 throw)—— standards.md §4.2 契约', async () => {
149
+ const { pi, tools, events } = createMockPi()
150
+ schedulerExtension(pi)
151
+ const fakeCtx = createFakeCtx()
152
+ await events.get('session_start')!({ type: 'session_start', reason: 'startup' }, fakeCtx)
153
+
154
+ // 非法 cron 表达式:parseSchedule 会 reject,handler 抛 'Invalid schedule',
155
+ // execute 应 catch 为 isError 而非让 promise reject。
156
+ const result = await tools[0]!.execute('call-err', { prompt: 'x', schedule: 'invalid-cron-expr-xxx' }, undefined, undefined, fakeCtx)
157
+ expect(result.isError).toBe(true)
158
+ expect(result.content[0].text).toContain('Error:')
159
+ })
160
+
161
+ it('schedule_control tool 同样 catch handler 错误', async () => {
162
+ const { pi, tools, events } = createMockPi()
163
+ schedulerExtension(pi)
164
+ const fakeCtx = createFakeCtx()
165
+ await events.get('session_start')!({ type: 'session_start', reason: 'startup' }, fakeCtx)
166
+
167
+ // toggle 不存在的 task id:handler 抛 'Task xxx not found',应被 catch
168
+ const controlTool = tools.find(t => t.name === 'schedule_control')!
169
+ const result = await controlTool.execute('call-ctrl', { action: 'toggle', id: 'deadbeef', enabled: false }, undefined, undefined, fakeCtx)
170
+ expect(result.isError).toBe(true)
171
+ })
172
+
173
+ it('registerCommand 注册了名为 schedule 的命令', () => {
174
+ const { pi, commands } = createMockPi()
175
+ schedulerExtension(pi)
176
+ expect(commands.map(c => c.name)).toContain('schedule')
177
+ })
178
+ })
@@ -0,0 +1,161 @@
1
+ import * as fs from 'node:fs'
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import { createStore, getStorePath } from '../store.js'
6
+ import type { SchedulerStore } from '../types.js'
7
+
8
+ // Mock fs 模块
9
+ vi.mock('node:fs', () => ({
10
+ existsSync: vi.fn(),
11
+ readFileSync: vi.fn(),
12
+ writeFileSync: vi.fn(),
13
+ mkdirSync: vi.fn(),
14
+ }))
15
+
16
+ describe('getStorePath', () => {
17
+ it('returns path under ~/.pi/agent/scheduler/', () => {
18
+ const p = getStorePath('/Users/test/project')
19
+ expect(p).toContain('.pi')
20
+ expect(p).toContain('agent')
21
+ expect(p).toContain('scheduler')
22
+ expect(p).toContain('scheduler.json')
23
+ })
24
+
25
+ it('generates different paths for different cwds', () => {
26
+ const p1 = getStorePath('/Users/test/project1')
27
+ const p2 = getStorePath('/Users/test/project2')
28
+ expect(p1).not.toBe(p2)
29
+ })
30
+ })
31
+
32
+ describe('createStore', () => {
33
+ const mockCwd = '/test/project'
34
+ let store: ReturnType<typeof createStore>
35
+
36
+ beforeEach(() => {
37
+ vi.clearAllMocks()
38
+ store = createStore(mockCwd)
39
+ })
40
+
41
+ afterEach(() => {
42
+ vi.restoreAllMocks()
43
+ })
44
+
45
+ describe('load', () => {
46
+ it('returns empty store when file does not exist', () => {
47
+ vi.mocked(fs.existsSync).mockReturnValue(false)
48
+ const result = store.load()
49
+ expect(result).toEqual({ version: 1, tasks: [] })
50
+ })
51
+
52
+ it('parses valid JSON file', () => {
53
+ const mockStore: SchedulerStore = {
54
+ version: 1,
55
+ tasks: [{
56
+ id: 'abc12345',
57
+ name: 'test',
58
+ prompt: 'test prompt',
59
+ kind: 'recurring',
60
+ schedule: { mode: 'interval', intervalMs: 60000 },
61
+ enabled: true,
62
+ force: false,
63
+ createdAt: Date.now(),
64
+ nextRunAt: Date.now() + 60000,
65
+ runCount: 0,
66
+ history: [],
67
+ }],
68
+ }
69
+ vi.mocked(fs.existsSync).mockReturnValue(true)
70
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockStore))
71
+ const result = store.load()
72
+ expect(result.tasks).toHaveLength(1)
73
+ expect(result.tasks[0]!.id).toBe('abc12345')
74
+ })
75
+
76
+ it('returns empty store on corrupted JSON', () => {
77
+ vi.mocked(fs.existsSync).mockReturnValue(true)
78
+ vi.mocked(fs.readFileSync).mockReturnValue('invalid json{{{')
79
+ const result = store.load()
80
+ expect(result).toEqual({ version: 1, tasks: [] })
81
+ })
82
+ })
83
+
84
+ describe('persistSync', () => {
85
+ it('writes store to file', () => {
86
+ const mockStore: SchedulerStore = { version: 1, tasks: [] }
87
+ store.persistSync(mockStore)
88
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
89
+ expect.stringContaining('scheduler.json'),
90
+ expect.any(String),
91
+ 'utf-8',
92
+ )
93
+ })
94
+
95
+ it('removes expired tasks', () => {
96
+ const now = Date.now()
97
+ const mockStore: SchedulerStore = {
98
+ version: 1,
99
+ tasks: [
100
+ {
101
+ id: 'expired',
102
+ name: 'expired task',
103
+ prompt: 'test',
104
+ kind: 'recurring',
105
+ schedule: { mode: 'interval', intervalMs: 60000 },
106
+ enabled: true,
107
+ force: false,
108
+ createdAt: now - 100000,
109
+ nextRunAt: now - 50000,
110
+ expiresAt: now - 10000,
111
+ runCount: 0,
112
+ history: [],
113
+ },
114
+ {
115
+ id: 'active',
116
+ name: 'active task',
117
+ prompt: 'test',
118
+ kind: 'recurring',
119
+ schedule: { mode: 'interval', intervalMs: 60000 },
120
+ enabled: true,
121
+ force: false,
122
+ createdAt: now,
123
+ nextRunAt: now + 60000,
124
+ runCount: 0,
125
+ history: [],
126
+ },
127
+ ],
128
+ }
129
+ store.persistSync(mockStore)
130
+ const written = JSON.parse(vi.mocked(fs.writeFileSync).mock.calls[0]![1] as string)
131
+ expect(written.tasks).toHaveLength(1)
132
+ expect(written.tasks[0].id).toBe('active')
133
+ })
134
+
135
+ it('trims history to 20 entries', () => {
136
+ const history = Array.from({ length: 25 }, (_, i) => ({
137
+ at: i * 1000,
138
+ status: 'success' as const,
139
+ }))
140
+ const mockStore: SchedulerStore = {
141
+ version: 1,
142
+ tasks: [{
143
+ id: 'task1',
144
+ name: 'task',
145
+ prompt: 'test',
146
+ kind: 'recurring',
147
+ schedule: { mode: 'interval', intervalMs: 60000 },
148
+ enabled: true,
149
+ force: false,
150
+ createdAt: 0,
151
+ nextRunAt: 60000,
152
+ runCount: 25,
153
+ history,
154
+ }],
155
+ }
156
+ store.persistSync(mockStore)
157
+ const written = JSON.parse(vi.mocked(fs.writeFileSync).mock.calls[0]![1] as string)
158
+ expect(written.tasks[0].history).toHaveLength(20)
159
+ })
160
+ })
161
+ })
@@ -0,0 +1,74 @@
1
+ import { beforeEach,describe, expect, it, vi } from 'vitest'
2
+
3
+ import { SchedulerRuntime } from '../runtime.js'
4
+ import { createScheduleControlHandler,createScheduleHandler } from '../tool.js'
5
+
6
+ const mockPi = { sendMessage: vi.fn() }
7
+ const mockCtx = { isIdle: () => true, hasPendingMessages: () => false }
8
+
9
+ describe('schedule tool', () => {
10
+ let runtime: SchedulerRuntime
11
+ let handler: ReturnType<typeof createScheduleHandler>
12
+
13
+ beforeEach(() => {
14
+ vi.clearAllMocks()
15
+ runtime = new SchedulerRuntime('/test', mockPi as never, mockCtx as never)
16
+ handler = createScheduleHandler(runtime)
17
+ })
18
+
19
+ it('creates task with duration', async () => {
20
+ const result = await handler({ prompt: 'check build', schedule: '5m' })
21
+ expect(result.content[0]!.text).toContain('Task "check build"')
22
+ expect(result.details.task.schedule).toEqual({ mode: 'interval', intervalMs: 300000 })
23
+ })
24
+
25
+ it('throws for invalid schedule', async () => {
26
+ await expect(handler({ prompt: 'test', schedule: 'invalid' }))
27
+ .rejects.toThrow('Invalid schedule')
28
+ })
29
+ })
30
+
31
+ describe('schedule_control tool', () => {
32
+ let runtime: SchedulerRuntime
33
+ let handler: ReturnType<typeof createScheduleControlHandler>
34
+
35
+ beforeEach(() => {
36
+ vi.clearAllMocks()
37
+ runtime = new SchedulerRuntime('/test', mockPi as never, mockCtx as never)
38
+ handler = createScheduleControlHandler(runtime)
39
+ })
40
+
41
+ it('lists tasks', async () => {
42
+ await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
43
+ const result = await handler({ action: 'list' })
44
+ expect(result.content[0]!.text).toContain('test')
45
+ })
46
+
47
+ it('returns empty message when no tasks', async () => {
48
+ const result = await handler({ action: 'list' })
49
+ expect(result.content[0]!.text).toBe('No scheduled tasks.')
50
+ })
51
+
52
+ it('toggles task', async () => {
53
+ const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
54
+ const result = await handler({ action: 'toggle', id: task.id, enabled: false })
55
+ expect(result.content[0]!.text).toContain('disabled')
56
+ })
57
+
58
+ it('throws for missing id on toggle', async () => {
59
+ await expect(handler({ action: 'toggle', enabled: true }))
60
+ .rejects.toThrow('id is required')
61
+ })
62
+
63
+ it('deletes task', async () => {
64
+ const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
65
+ const result = await handler({ action: 'delete', id: task.id })
66
+ expect(result.content[0]!.text).toContain('deleted')
67
+ })
68
+
69
+ it('runs task now', async () => {
70
+ const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
71
+ const result = await handler({ action: 'run', id: task.id })
72
+ expect(result.content[0]!.text).toContain('executed')
73
+ })
74
+ })