+func TestSchedulesRunStartsATurnWhenDue(t *testing.T) {
+ clock := &ScheduleTestClock{
+ now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
+ }
+
+ logs := &SyncBuffer{}
+ schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), clock.Now, daemonTestLogger(logs))
+ require.NoError(t, err)
+
+ server := newModelFake(t)
+ test := scheduleTestRun(t, schedules, server)
+
+ _, err = schedules.Set("Remind the user to drink water.", new("2026-11-08 20:05"), nil)
+ require.NoError(t, err)
+
+ clock.Set(clock.Now().Add(5 * time.Minute))
+ test.events.WaitFor(t, "idle", 1)
+ require.Equal(t, 1, server.Calls())
+
+ texts := turnInputTexts(t, server.Call(0))
+ require.Equal(t, "[Schedule 1] Sunday 2026-11-08 20:05 -03:00, once\nRemind the user to drink water.\n"+scheduleEmptyReply, texts[len(texts)-1])
+ require.Equal(t, scheduleSource, test.events.LastEvent("work").Source)
+ require.Equal(t, "No schedules.", schedules.List())
+ require.Equal(t, "<6>schedule ran id=1\n", logs.String())
+}
+
+func TestSchedulesRunMissedSchedulesOnceAtTheStart(t *testing.T) {
+ path := filepath.Join(t.TempDir(), scheduleFileName)
+ require.NoError(t, os.WriteFile(path, []byte(`{"next":2,"schedules":[{"id":1,"text":"Remind the user to call.","cron":"","due":"2026-11-08T09:00:00-03:00"},{"id":2,"text":"Tell the user the time.","cron":"0 * * * *","due":"2026-11-08T10:00:00-03:00"}]}`), 0o600))
+
+ clock := &ScheduleTestClock{
+ now: time.Date(2026, time.November, 8, 20, 30, 0, 0, time.FixedZone("", -3*60*60)),
+ }
+
+ logs := &SyncBuffer{}
+ schedules, err := openSchedules(path, clock.Now, daemonTestLogger(logs))
+ require.NoError(t, err)
+
+ server := newModelFake(t)
+ test := scheduleTestRun(t, schedules, server)
+ test.events.WaitFor(t, "idle", 1)
+ require.Equal(t, 1, server.Calls())
+
+ texts := turnInputTexts(t, server.Call(0))
+ require.Equal(t, []string{
+ "[Schedule 1] Sunday 2026-11-08 09:00 -03:00, once\nRemind the user to call.\n" + scheduleLateLine + "\n" + scheduleEmptyReply,
+ "[Schedule 2] Sunday 2026-11-08 10:00 -03:00, repeats 0 * * * *\nTell the user the time.\n" + scheduleLateLine + "\n" + scheduleEmptyReply,
+ }, texts[len(texts)-2:])
+ require.Equal(t, "2 | Sunday 2026-11-08 21:00 -03:00 | 0 * * * * | Tell the user the time.", schedules.List())
+ require.Equal(t, "<6>schedule ran id=1 late=11h30m0s\n<6>schedule ran id=2 late=10h30m0s\n", logs.String())
+}
+
+func TestDaemonRunsADueSchedule(t *testing.T) {
+ clock := &ScheduleTestClock{
+ now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
+ }
+
+ schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), clock.Now, discardLog())
+ require.NoError(t, err)
+
+ schedules.check = time.Millisecond
+ whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
+ socket, _ := daemonStartTestWithSchedules(t, newModelFake(t), nil, whatsapp, schedules, clock.Now)
+ client := terminalStartTestClient(t, socket)
+ client.WaitFor(t, "[connected to")
+
+ _, err = schedules.Set("Remind the user to drink water.", new("2026-11-08 20:05"), nil)
+ require.NoError(t, err)
+
+ clock.Set(clock.Now().Add(5 * time.Minute))
+ client.WaitFor(t, "[Schedule 1] Sunday 2026-11-08 20:05 -03:00, once\nRemind the user to drink water.\n"+scheduleEmptyReply+"\n[working]\na line\n[done]")
+}
+
+func TestToolboxRunsTheScheduleTools(t *testing.T) {
+ schedules, _ := scheduleTestOpen(t)
+ box := &Toolbox{
+ schedules: schedules,
+ }
+
+ require.Equal(t, "Schedule 1 set. Next: Monday 2026-11-09 08:00 -03:00.", box.run(t.Context(), "schedule_set", `{"text":"Tell the user the news.","at":null,"cron":"0 8 * * 1-5"}`))
+ require.Equal(t, "Schedule 2 set. Next: Sunday 2026-11-08 20:02 -03:00.", box.run(t.Context(), "schedule_set", `{"text":"Remind the user to drink water.","at":"2026-11-08 20:02","cron":null}`))
+ require.Equal(t, "2 | Sunday 2026-11-08 20:02 -03:00 | once | Remind the user to drink water.\n1 | Monday 2026-11-09 08:00 -03:00 | 0 8 * * 1-5 | Tell the user the news.", box.run(t.Context(), "schedule_list", `{}`))
+ require.Equal(t, "Schedule 1 removed.", box.run(t.Context(), "schedule_remove", `{"id":1}`))
+ require.Equal(t, "error: no schedule 1", box.run(t.Context(), "schedule_remove", `{"id":1}`))
+ require.Equal(t, "error: give either at or cron, and null for the other", box.run(t.Context(), "schedule_set", `{"text":"x","at":null,"cron":null}`))
+}
+
+func TestSubagentHasNoScheduleTools(t *testing.T) {
+ box := &Toolbox{}
+ require.Equal(t, `error: unknown tool "schedule_set"`, box.run(t.Context(), "schedule_set", `{"text":"x","at":"2026-11-09 09:00","cron":null}`))
+ require.Equal(t, `error: unknown tool "schedule_list"`, box.run(t.Context(), "schedule_list", `{}`))
+ require.Equal(t, `error: unknown tool "schedule_remove"`, box.run(t.Context(), "schedule_remove", `{"id":1}`))
+}
+
+// ScheduleTestClock is a clock that a test sets while Run reads it.
+type ScheduleTestClock struct {
+ mutex sync.Mutex
+ now time.Time
+}
+
+// Now returns the time of the clock.
+func (c *ScheduleTestClock) Now() time.Time {
+ c.mutex.Lock()
+ defer c.mutex.Unlock()
+
+ return c.now
+}
+
+// Set sets the time of the clock.
+func (c *ScheduleTestClock) Set(now time.Time) {
+ c.mutex.Lock()
+ defer c.mutex.Unlock()
+
+ c.now = now
+}
+