Repository navigation
Expand file tree
/
Copy pathscheduler.test.ts
More file actions
524 lines (488 loc) · 24.4 KB
/
Copy pathscheduler.test.ts
File metadata and controls
524 lines (488 loc) · 24.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
import { afterEach, expect, spyOn, test } from "bun:test"
import { rm } from "node:fs/promises"
import { executeReminder, pauseReminder, beginSchedulerGeneration, reconcileReminder, scheduleTimer } from "./scheduler"
import { saveReminder, loadReminder } from "./storage"
import { restoreReminders, createTools } from "./index"
import { ReminderSchema, type PluginConfig, type Reminder, type State } from "./types"
import { createFakeHost, createReminder, createState, matchedContext, nextMessageID, type FakeHost } from "./test-host"
const config: PluginConfig = { enabled: true, max_reminders_per_project: 50, min_interval_seconds: 30 }
const directories: string[] = []
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
})
async function createFixture(type: Reminder["type"]) {
const host = await createFakeHost()
directories.push(host.directory)
const state = createState(host.projectID)
const reminder = createReminder(host.projectID, type)
host.agents.set(reminder.sessionID, "build")
state.reminders.set(reminder.id, reminder)
return { host, state, reminder }
}
test("scheduler refuses a mismatched captured agent without switching or prompting", async () => {
const { host, state, reminder } = await createFixture("one-time")
reminder.agent = "agent-a"
let switches = 0
host.session.switchAgent = async () => { switches++ }
await saveReminder(reminder, host)
await executeReminder(reminder, host, state, config)
expect(switches).toBe(0)
expect(host.prompts).toHaveLength(0)
expect((await loadReminder(reminder.id, host))?.status).toBe("paused")
})
test("scheduler does not switch the agent when it already matches", async () => {
const { host, state, reminder } = await createFixture("one-time")
reminder.agent = "build"
host.agents.set(reminder.sessionID, "build")
let switched = 0
host.session.switchAgent = async () => { switched++ }
await saveReminder(reminder, host)
await executeReminder(reminder, host, state, config)
expect(switched).toBe(0)
expect(host.prompts).toHaveLength(1)
})
test("scheduler omits agent switching for legacy reminders", async () => {
const { host, state, reminder } = await createFixture("one-time")
let switched = 0
host.session.switchAgent = async () => { switched++ }
await saveReminder(reminder, host)
await executeReminder(reminder, host, state, config)
expect(switched).toBe(0)
expect(host.prompts[0]).toMatchObject({ text: "test prompt" })
expect("agents" in host.prompts[0]).toBe(false)
})
test("reminder schema accepts legacy records without an agent and rejects malformed ones", async () => {
const { reminder } = await createFixture("one-time")
expect(ReminderSchema.parse(reminder).agent).toBeUndefined()
expect(ReminderSchema.safeParse({ ...reminder, status: "weird" }).success).toBe(false)
expect(ReminderSchema.safeParse({ ...reminder, time: { created: "x", nextExecution: 1 } }).success).toBe(false)
})
for (const agent of ["build", "agent-b", "agent-a", "agent-c"]) {
test(`monitor uses action model resolution without changing originating ${agent} authority`, async () => {
const { host, state, reminder } = await createFixture("one-time")
reminder.agent = agent
host.agents.set(reminder.sessionID, agent)
reminder.execution = "monitor"
await saveReminder(reminder, host)
await executeReminder(reminder, host, state, config)
const request = host.prompts[0]
expect(request.sessionID).toBe(reminder.sessionID)
expect(host.agents.get(reminder.sessionID)).toBe(agent)
expect("model" in request).toBe(false)
expect("variant" in request).toBe(false)
expect(Object.keys(request).sort()).toEqual(["delivery", "id", "metadata", "sessionID", "text"])
expect(request.delivery).toBe("queue")
expect(await loadReminder(reminder.id, host)).toBeNull()
expect(request.text).toContain("No change.")
expect(request.text).toContain(`reminder_id=${reminder.id}`)
})
}
// A pause from inside the prompt must survive the post-prompt recurring transition.
test("blocked monitor pauses durably during execution and never re-prompts", async () => {
const { host, state, reminder } = await createFixture("recurring")
reminder.agent = "build"
reminder.execution = "monitor"
let calls = 0
const prompt = host.session.prompt
host.session.prompt = async (request) => {
calls++
expect(await pauseReminder(reminder.id, reminder.sessionID, "Needs approval", host, state)).toBe(true)
return prompt(request)
}
await saveReminder(reminder, host)
await executeReminder(reminder, host, state, config)
expect((await loadReminder(reminder.id, host))?.status).toBe("paused")
expect(state.reminders.get(reminder.id)?.pauseReason).toBe("Needs approval")
expect(state.timers.size).toBe(0)
await executeReminder(reminder, host, state, config)
expect(calls).toBe(1)
expect(await pauseReminder(reminder.id, reminder.sessionID, "Repeated approval", host, state)).toBe(true)
expect((await loadReminder(reminder.id, host))?.pauseReason).toBe("Needs approval")
})
test("pause is session-scoped, unknown IDs cannot become paths", async () => {
const { host, state, reminder } = await createFixture("recurring")
await saveReminder(reminder, host)
expect(await pauseReminder(reminder.id, "other-session", "No", host, state)).toBe(false)
expect(await pauseReminder("../../escape", reminder.sessionID, "No", host, state)).toBe(false)
expect((await loadReminder(reminder.id, host))?.status).toBe("active")
})
test("paused snapshots restore after grace period without timers and can be listed/removed", async () => {
const { host, state, reminder } = await createFixture("recurring")
reminder.status = "paused"
reminder.pauseReason = "User approval"
reminder.time.nextExecution = Date.now() - 86_400_000
await saveReminder(reminder, host)
const generation = beginSchedulerGeneration(host)
const restored: State = { ...createState(host.projectID), generation }
expect(await restoreReminders(host, restored, config, generation)).toBe(true)
const tools = Object.fromEntries(createTools(host, restored, () => config).map((tool) => [tool.name, tool]))
const context = { sessionID: reminder.sessionID, agent: "build" }
const listing = await tools.reminderlist.execute({}, context)
expect(listing.content).toContain("paused: User approval")
expect(listing.content).toContain(reminder.id)
expect((await loadReminder(reminder.id, host))?.status).toBe("paused")
await tools.reminderremove.execute({ description_pattern: reminder.userDescription }, context)
expect(await loadReminder(reminder.id, host)).toBeNull()
beginSchedulerGeneration(host)
})
test("restore cleans expired active reminders and reschedules live ones", async () => {
const { host, reminder } = await createFixture("recurring")
reminder.time.nextExecution = Date.now() - 86_400_000
await saveReminder(reminder, host)
const live = createReminder(host.projectID, "one-time")
live.time.nextExecution = Date.now() + 60_000
await saveReminder(live, host)
const generation = beginSchedulerGeneration(host)
const restored: State = { ...createState(host.projectID), generation }
expect(await restoreReminders(host, restored, config, generation)).toBe(true)
expect(await loadReminder(reminder.id, host)).toBeNull()
expect(restored.reminders.has(live.id)).toBe(true)
expect(restored.timers.has(live.id)).toBe(true)
beginSchedulerGeneration(host)
})
test("pause clears scheduled timer and stale reconciliation cannot resurrect it", async () => {
const { host, state, reminder } = await createFixture("recurring")
reminder.time.nextExecution = Date.now() + 30_000
await scheduleTimer(reminder, host, state, config)
expect(state.timers.size).toBe(1)
expect(await pauseReminder(reminder.id, reminder.sessionID, "Blocked", host, state)).toBe(true)
await reconcileReminder(reminder.id, host, state, config)
expect(state.timers.size).toBe(0)
expect(state.reminders.get(reminder.id)?.status).toBe("paused")
expect(await scheduleTimer(state.reminders.get(reminder.id)!, host, state, config)).toBe(false)
})
test("monitor with missing authority never calls the model", async () => {
const { host, state, reminder } = await createFixture("one-time")
reminder.execution = "monitor"
await saveReminder(reminder, host)
await executeReminder(reminder, host, state, config)
expect(host.prompts).toHaveLength(0)
})
test("persisted routing wins over stale timer snapshot", async () => {
const { host, state, reminder } = await createFixture("one-time")
reminder.agent = "agent-a"
await saveReminder({ ...reminder, agent: "build", execution: "monitor" }, host)
await executeReminder(reminder, host, state, config)
expect(host.agents.get(reminder.sessionID)).toBe("build")
expect(host.prompts[0].text).toContain("[Scheduled bounded monitor")
})
for (const failure of ["throw", "wait-throw", "assistant-error"]) {
test(`monitor ${failure} pauses rather than creating a paid retry loop`, async () => {
const { host, state, reminder } = await createFixture("recurring")
reminder.agent = "build"
reminder.execution = "monitor"
let calls = 0
const prompt = host.session.prompt
host.session.prompt = async (request) => {
calls++
if (failure === "throw") throw new Error("Model unavailable")
return prompt(request)
}
if (failure === "wait-throw") host.session.wait = async () => { throw new Error("Unavailable") }
if (failure === "assistant-error") {
host.contextFor = (sessionID) => {
const messages = matchedContext(host, sessionID)
messages[1]!.error = { _tag: "ProviderError" }
return messages
}
}
await saveReminder(reminder, host)
await executeReminder(reminder, host, state, config)
expect((await loadReminder(reminder.id, host))?.status).toBe("paused")
expect(state.timers.size).toBe(0)
await executeReminder(reminder, host, state, config)
expect(calls).toBe(1)
})
}
test("same occurrence lease prevents overlapping monitor prompts", async () => {
const { host, state, reminder } = await createFixture("recurring")
reminder.agent = "build"
reminder.execution = "monitor"
let release!: () => void
let entered!: () => void
const gate = new Promise<void>((resolve) => { release = resolve })
const started = new Promise<void>((resolve) => { entered = resolve })
let calls = 0
const prompt = host.session.prompt
host.session.prompt = async (request) => { calls++; entered(); await gate; return prompt(request) }
await saveReminder(reminder, host)
const first = executeReminder(reminder, host, state, config)
await started
await executeReminder(reminder, host, state, config)
release()
await first
expect(calls).toBe(1)
await pauseReminder(reminder.id, reminder.sessionID, "Test complete", host, state)
beginSchedulerGeneration(host)
})
for (const status of ["busy", "retry"] as const) {
test(`monitor defers ${status} without submitting a prompt or recording completion`, async () => {
const { host, state, reminder } = await createFixture("recurring")
reminder.agent = "build"
reminder.execution = "monitor"
const occurrence = reminder.time.nextExecution
host.statuses.set(reminder.sessionID, status)
await saveReminder(reminder, host)
await executeReminder(reminder, host, state, config)
const stored = await loadReminder(reminder.id, host)
expect(host.prompts).toHaveLength(0)
expect(stored?.status).toBe("active")
expect(stored?.time.lastExecution).toBeUndefined()
expect(stored!.time.nextExecution).toBeGreaterThan(occurrence)
expect(state.timers.size).toBe(1)
beginSchedulerGeneration(host)
})
}
test("monitor with no observed status relies on durable queue admission", async () => {
const { host, state, reminder } = await createFixture("recurring")
reminder.agent = "build"
reminder.execution = "monitor"
await saveReminder(reminder, host)
await executeReminder(reminder, host, state, config)
expect(host.prompts).toHaveLength(1)
expect(host.prompts[0].delivery).toBe("queue")
expect((await loadReminder(reminder.id, host))?.time.lastExecution).toBeDefined()
beginSchedulerGeneration(host)
})
for (const failure of ["get-throw", "get-mismatch", "get-missing"]) {
test(`monitor session verification ${failure} fails closed without a model call`, async () => {
const { host, state, reminder } = await createFixture("recurring")
reminder.agent = "build"
reminder.execution = "monitor"
host.session.get = async ({ sessionID }) => {
if (failure === "get-throw") throw new Error("Not found")
if (failure === "get-missing") return undefined
return { id: `${sessionID}-other` }
}
await saveReminder(reminder, host)
await executeReminder(reminder, host, state, config)
expect(host.prompts).toHaveLength(0)
expect((await loadReminder(reminder.id, host))?.status).toBe("paused")
expect((await loadReminder(reminder.id, host))?.time.lastExecution).toBeUndefined()
expect(state.timers.size).toBe(0)
})
}
for (const mismatch of ["missing-user", "session", "role", "incomplete", "empty-context"]) {
test(`monitor completion with ${mismatch} mismatch pauses without advancing the occurrence`, async () => {
const { host, state, reminder } = await createFixture("recurring")
reminder.agent = "build"
reminder.execution = "monitor"
const occurrence = reminder.time.nextExecution
host.statuses.set(reminder.sessionID, "idle")
host.contextFor = (sessionID) => {
if (mismatch === "empty-context") return []
const messages = matchedContext(host, sessionID)
if (mismatch === "missing-user") messages[0]!.id = nextMessageID()
if (mismatch === "role") messages[1]!.type = "synthetic"
if (mismatch === "incomplete") messages[1]!.time = { created: Date.now() }
return messages
}
if (mismatch === "session") {
const prompt = host.session.prompt
host.session.prompt = async (request) => ({ ...(await prompt(request))!, sessionID: "ses_other" })
}
await saveReminder(reminder, host)
await executeReminder(reminder, host, state, config)
const stored = await loadReminder(reminder.id, host)
expect(stored?.status).toBe("paused")
expect(stored?.time.lastExecution).toBeUndefined()
expect(stored?.time.nextExecution).toBe(occurrence)
expect(state.timers.size).toBe(0)
})
}
test("ordinary action does not inspect busy status", async () => {
const { host, state, reminder } = await createFixture("one-time")
reminder.agent = "build"
host.statuses.set(reminder.sessionID, "busy")
host.status = () => { throw new Error("Must not inspect action status") }
await saveReminder(reminder, host)
await executeReminder(reminder, host, state, config)
expect(host.prompts[0]).toMatchObject({ sessionID: reminder.sessionID, text: reminder.originalPrompt, delivery: "queue" })
expect(await loadReminder(reminder.id, host)).toBeNull()
})
test("pause during session verification prevents admission", async () => {
const { host, state, reminder } = await createFixture("recurring")
reminder.agent = "build"
reminder.execution = "monitor"
const get = host.session.get
host.session.get = async (input) => {
await pauseReminder(reminder.id, reminder.sessionID, "Approval required", host, state)
return get(input)
}
await saveReminder(reminder, host)
await executeReminder(reminder, host, state, config)
expect(host.prompts).toHaveLength(0)
expect((await loadReminder(reminder.id, host))?.status).toBe("paused")
})
test("a successful turn resets the consecutive failure count", async () => {
const { host, state, reminder } = await createFixture("recurring")
reminder.interval = 60_000
reminder.consecutiveFailures = 4
await saveReminder(reminder, host)
await executeReminder(reminder, host, state, config)
const stored = await loadReminder(reminder.id, host)
expect(stored?.status).toBe("active")
expect(stored?.consecutiveFailures).toBeUndefined()
expect(stored?.time.lastExecution).toBeNumber()
beginSchedulerGeneration(host)
})
test("reminder schema keeps consecutive failures compatible with older snapshots", () => {
const reminder = createReminder("project", "recurring")
expect(ReminderSchema.safeParse(reminder).success).toBe(true)
expect(ReminderSchema.parse({ ...reminder, consecutiveFailures: 2 }).consecutiveFailures).toBe(2)
expect("consecutiveFailures" in ReminderSchema.parse({ ...reminder, consecutiveFailures: 0 })).toBe(false)
expect(ReminderSchema.safeParse({ ...reminder, consecutiveFailures: -1 }).success).toBe(false)
expect(ReminderSchema.safeParse({ ...reminder, consecutiveFailures: 1.5 }).success).toBe(false)
expect(ReminderSchema.safeParse({ ...reminder, consecutiveFailures: "2" }).success).toBe(false)
})
test("restore keeps failure counts on active reminders and leaves failure-paused ones paused", async () => {
const { host, reminder } = await createFixture("recurring")
reminder.interval = 60_000
reminder.consecutiveFailures = 2
reminder.time.nextExecution = Date.now() + 60_000
await saveReminder(reminder, host)
const paused = createReminder(host.projectID, "recurring")
paused.status = "paused"
paused.consecutiveFailures = 5
paused.pauseReason = "Paused automatically after 5 consecutive failed turns (last: Reminder turn failed)."
paused.time.nextExecution = Date.now() - 86_400_000
await saveReminder(paused, host)
const generation = beginSchedulerGeneration(host)
const restored: State = { ...createState(host.projectID), generation }
expect(await restoreReminders(host, restored, config, generation)).toBe(true)
expect(restored.reminders.get(reminder.id)?.consecutiveFailures).toBe(2)
expect(restored.timers.has(reminder.id)).toBe(true)
expect(restored.reminders.get(paused.id)?.status).toBe("paused")
expect(restored.reminders.get(paused.id)?.consecutiveFailures).toBe(5)
expect(restored.timers.has(paused.id)).toBe(false)
expect((await loadReminder(paused.id, host))?.status).toBe("paused")
beginSchedulerGeneration(host)
})
test("a long timer is clamped and re-armed when it fires before its due time", async () => {
const { host, state, reminder } = await createFixture("one-time")
reminder.time.nextExecution = Date.now() + 30 * 24 * 60 * 60_000
await scheduleTimer(reminder, host, state, config)
const timer = state.timers.get(reminder.id)! as any
expect(timer._idleTimeout).toBe(2_147_483_647)
clearTimeout(timer)
await timer._onTimeout()
expect(host.prompts).toHaveLength(0)
expect(state.timers.get(reminder.id)).toBeDefined()
expect((await loadReminder(reminder.id, host))?.status).toBe("active")
beginSchedulerGeneration(host)
})
test("a clamped timer executes only once the due time has arrived", async () => {
const { host, state, reminder } = await createFixture("one-time")
reminder.time.nextExecution = Date.now() + 30 * 24 * 60 * 60_000
await scheduleTimer(reminder, host, state, config)
const timer = state.timers.get(reminder.id)! as any
clearTimeout(timer)
const clock = spyOn(Date, "now").mockReturnValue(reminder.time.nextExecution)
try {
await timer._onTimeout()
expect(host.prompts).toHaveLength(1)
expect(await loadReminder(reminder.id, host)).toBeNull()
} finally {
clock.mockRestore()
beginSchedulerGeneration(host)
}
})
test("a failed one-time action remains paused for inspection", async () => {
const { host, state, reminder } = await createFixture("one-time")
host.session.prompt = async () => { throw new Error("Admission uncertain") }
await saveReminder(reminder, host)
await executeReminder(reminder, host, state, config)
expect((await loadReminder(reminder.id, host))?.status).toBe("paused")
expect(state.timers.size).toBe(0)
})
for (const mode of ["later-failure", "intervening-user", "later-incomplete", "finish-error", "steered"] as const) {
test(`full turn ${mode} pauses without crediting completion`, async () => {
const { host, state, reminder } = await createFixture("recurring")
host.contextFor = (sessionID) => {
const messages = matchedContext(host, sessionID)
if (mode === "finish-error") messages[1]!.finish = "error"
if (mode === "steered") messages[1]!.interruption = "steer"
if (mode === "intervening-user") messages.splice(-1, 0, { id: nextMessageID(), type: "user", time: { created: Date.now() } })
messages.splice(-1, 0, { id: nextMessageID(), type: "assistant", time: mode === "later-incomplete" ? { created: Date.now() } : { created: Date.now(), completed: Date.now() }, ...(mode === "later-failure" ? { error: { message: "failed" } } : {}) })
return messages
}
await saveReminder(reminder, host)
await executeReminder(reminder, host, state, config)
const saved = await loadReminder(reminder.id, host)
expect(saved?.status).toBe("paused")
expect(saved?.time.lastExecution).toBeUndefined()
expect(state.timers.size).toBe(0)
})
}
test("reload after admission leaves the occurrence paused rather than replaying it", async () => {
const { host, state, reminder } = await createFixture("recurring")
let entered!: () => void
let release!: () => void
const enteredWait = new Promise<void>((resolve) => { entered = resolve })
const wait = new Promise<void>((resolve) => { release = resolve })
host.session.wait = async () => { entered(); await wait }
await saveReminder(reminder, host)
const running = executeReminder(reminder, host, state, config)
await enteredWait
expect(host.prompts).toHaveLength(1)
const generation = beginSchedulerGeneration(host)
const replacement: State = { ...createState(host.projectID), generation }
expect(await restoreReminders(host, replacement, config, generation)).toBe(true)
expect(replacement.reminders.get(reminder.id)?.status).toBe("paused")
expect(replacement.timers.size).toBe(0)
await executeReminder((await loadReminder(reminder.id, host))!, host, replacement, config)
expect(host.prompts).toHaveLength(1)
release()
await running
expect((await loadReminder(reminder.id, host))?.status).toBe("paused")
beginSchedulerGeneration(host)
})
test("a busy session with a different agent is never switched or prompted", async () => {
const { host, state, reminder } = await createFixture("recurring")
reminder.agent = "agent-a"
host.statuses.set(reminder.sessionID, "busy")
let switches = 0
host.session.switchAgent = async () => { switches++ }
await saveReminder(reminder, host)
await executeReminder(reminder, host, state, config)
expect(switches).toBe(0)
expect(host.prompts).toHaveLength(0)
expect((await loadReminder(reminder.id, host))?.status).toBe("paused")
})
test("stock v2 multi-step assistant sequence followed by successful idle is credited", async () => {
const { host, state, reminder } = await createFixture("recurring")
reminder.interval = 60_000
host.contextFor = (sessionID) => {
const messages = matchedContext(host, sessionID)
messages[1]!.finish = "tool-calls"
messages.splice(-1, 0, { id: nextMessageID(), type: "assistant", agent: "build", model: { id: "test", providerID: "test" }, content: [{ type: "text", text: "done" }], finish: "stop", time: { created: Date.now(), completed: Date.now() } })
return messages
}
await saveReminder(reminder, host)
await executeReminder(reminder, host, state, config)
const saved = await loadReminder(reminder.id, host)
expect(saved?.status).toBe("active")
expect(saved?.time.lastExecution).toBeNumber()
expect(state.timers.has(reminder.id)).toBe(true)
beginSchedulerGeneration(host)
})
for (const mode of ["failed", "interrupted", "missing", "user-after-idle", "duplicate-idle", "no-assistant"] as const) {
test(`v2 idle boundary ${mode} is not credited`, async () => {
const { host, state, reminder } = await createFixture("recurring")
host.contextFor = (sessionID) => {
const messages = matchedContext(host, sessionID)
if (mode === "failed" || mode === "interrupted") messages[messages.length - 1]!.outcome = mode
if (mode === "missing") messages.pop()
if (mode === "user-after-idle") messages.push({ id: nextMessageID(), type: "user", time: { created: Date.now() } })
if (mode === "duplicate-idle") messages.push({ id: nextMessageID(), type: "idle", outcome: "succeeded", time: { created: Date.now() } })
if (mode === "no-assistant") messages.splice(1, 1)
return messages
}
await saveReminder(reminder, host)
await executeReminder(reminder, host, state, config)
const saved = await loadReminder(reminder.id, host)
expect(saved?.status).toBe("paused")
expect(saved?.time.lastExecution).toBeUndefined()
expect(state.timers.size).toBe(0)
})
}