package main import ( "context" "os" "path/filepath" "strings" "sync" "testing" "testing/synctest" "time" "github.com/stretchr/testify/require" ) func TestSchedulesSet(t *testing.T) { testCases := []struct { name string text string at *string cron *string answer string listed string }{ { name: "a one-time schedule at a local time", text: "Remind the user to drink water.", at: new("2026-11-09 09:00"), answer: "Schedule 1 set. Next: Monday 2026-11-09 09:00 -03:00.", listed: "1 | Monday 2026-11-09 09:00 -03:00 | once | Remind the user to drink water.", }, { name: "a repeating schedule at the next match", text: "Tell the user\nthe time.", cron: new("*/5 * * * *"), answer: "Schedule 1 set. Next: Sunday 2026-11-08 20:05 -03:00.", listed: "1 | Sunday 2026-11-08 20:05 -03:00 | */5 * * * * | Tell the user the time.", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { schedules, _ := scheduleTestOpen(t) answer, err := schedules.Set(tc.text, tc.at, tc.cron) require.NoError(t, err) require.Equal(t, tc.answer, answer) require.Equal(t, tc.listed, schedules.List()) }) } } func TestSchedulesSetRefuses(t *testing.T) { testCases := []struct { name string text string at *string cron *string message string }{ { name: "an empty text", text: " \n", at: new("2026-11-09 09:00"), message: "the text must have 1 to 4096 bytes", }, { name: "a text that is too long", text: strings.Repeat("x", scheduleTextBytes+1), at: new("2026-11-09 09:00"), message: "the text must have 1 to 4096 bytes", }, { name: "neither at nor cron", text: "Tell the user the time.", message: "give either at or cron, and null for the other", }, { name: "both at and cron", text: "Tell the user the time.", at: new("2026-11-09 09:00"), cron: new("0 9 * * *"), message: "give either at or cron, and null for the other", }, { name: "a time in another layout", text: "Tell the user the time.", at: new("2026-11-09T09:00:00-03:00"), message: "at must be a local time such as 2026-11-08 20:00", }, { name: "a time that is past", text: "Tell the user the time.", at: new("2026-11-08 20:00"), message: "the time Sunday 2026-11-08 20:00 -03:00 is not after now, Sunday 2026-11-08 20:00 -03:00", }, { name: "runs less than 5 minutes apart", text: "Tell the user the time.", cron: new("* * * * *"), message: "at least 5 minutes apart", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { schedules, path := scheduleTestOpen(t) _, err := schedules.Set(tc.text, tc.at, tc.cron) require.ErrorContains(t, err, tc.message) require.NoFileExists(t, path) }) } } func TestSchedulesSurviveARestart(t *testing.T) { schedules, path := scheduleTestOpen(t) _, err := schedules.Set("Remind the user to drink water.", new("2026-11-09 09:00"), nil) require.NoError(t, err) _, err = schedules.Set("Tell the user the news.", nil, new("0 8 * * 1-5")) require.NoError(t, err) _, err = schedules.Remove(1) require.NoError(t, err) info, err := os.Stat(path) require.NoError(t, err) require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) reopened, err := openSchedules(path, "OneChat", schedules.clock, discardLog()) require.NoError(t, err) require.Equal(t, "2 | Monday 2026-11-09 08:00 -03:00 | 0 8 * * 1-5 | Tell the user the news.", reopened.List()) answer, err := reopened.Set("Remind the user to call.", new("2026-11-08 21:00"), nil) require.NoError(t, err) require.Equal(t, "Schedule 3 set. Next: Sunday 2026-11-08 21:00 -03:00.", answer) } func TestSchedulesRemove(t *testing.T) { schedules, _ := scheduleTestOpen(t) _, err := schedules.Set("Remind the user to drink water.", new("2026-11-09 09:00"), nil) require.NoError(t, err) answer, err := schedules.Remove(1) require.NoError(t, err) require.Equal(t, "Schedule 1 removed.", answer) require.Equal(t, "No schedules.", schedules.List()) _, err = schedules.Remove(1) require.EqualError(t, err, "no schedule 1") } func TestSchedulesDone(t *testing.T) { schedules, _ := scheduleTestOpen(t) start := schedules.clock() _, err := schedules.Set("Remind the user to drink water.", new("2026-11-08 21:00"), nil) require.NoError(t, err) _, err = schedules.Set("Tell the user the time.", nil, new("0 * * * *")) require.NoError(t, err) wait, found := schedules.Wait(start) require.True(t, found) require.Equal(t, time.Hour, wait) // The daemon was stopped for three hours, so each schedule missed its // times and runs once. now := start.Add(3*time.Hour + 30*time.Minute) due := schedules.Due(now) require.Len(t, due, 2) for _, schedule := range due { require.NoError(t, schedules.Done(schedule, now)) } require.Empty(t, schedules.Due(now)) require.Equal(t, "2 | Monday 2026-11-09 00:00 -03:00 | 0 * * * * | Tell the user the time.", schedules.List()) _, err = schedules.Remove(2) require.NoError(t, err) require.NoError(t, schedules.Done(due[1], now)) require.Equal(t, "No schedules.", schedules.List()) _, found = schedules.Wait(now) require.False(t, found) } func TestScheduleMessage(t *testing.T) { zone := time.FixedZone("", -3*60*60) due := time.Date(2026, time.November, 9, 9, 0, 0, 0, zone) testCases := []struct { name string schedule Schedule now time.Time message string }{ { name: "a one-time run on time", schedule: Schedule{ ID: 3, Text: "Remind the user to drink water.", Due: due, }, now: due.Add(time.Second), message: "[Schedule 3] Monday 2026-11-09 09:00 -03:00, once\nRemind the user to drink water.\n" + scheduleEmptyReply, }, { name: "a late repeating run", schedule: Schedule{ ID: 4, Text: "Tell the user the news.", Cron: "0 9 * * 1-5", Due: due, }, now: due.Add(2 * time.Hour), message: "[Schedule 4] Monday 2026-11-09 09:00 -03:00, repeats 0 9 * * 1-5\nTell the user the news.\n" + scheduleLateLine + "\n" + scheduleEmptyReply, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.message, tc.schedule.Message(tc.now)) }) } } func TestOpenSchedulesRefusesABadCron(t *testing.T) { path := filepath.Join(t.TempDir(), scheduleFileName) require.NoError(t, os.WriteFile(path, []byte(`{"next":1,"schedules":[{"id":1,"text":"x","cron":"* * * * *","due":"2026-11-09T09:00:00-03:00"}]}`), 0o600)) _, err := openSchedules(path, "OneChat", time.Now, discardLog()) require.ErrorContains(t, err, "schedule 1: the runs of") } 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), "OneChat", 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)) schedules.changed <- struct{}{} 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, "OneChat", 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), "OneChat", clock.Now, discardLog()) require.NoError(t, err) 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)) schedules.changed <- struct{}{} 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 TestSchedulesCheckTheClockAfterItsWaitLimit(t *testing.T) { synctest.Test(t, func(t *testing.T) { whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), newTestClock()) loop, events := newTestIdleTurnLoop(t, whatsapp) clock := &ScheduleTestClock{now: time.Now()} schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), "OneChat", clock.Now, discardLog()) require.NoError(t, err) _, err = schedules.Set("Check the clock.", new(clock.Now().Add(5*time.Minute).Format(scheduleAtLayout)), nil) require.NoError(t, err) ctx, cancel := context.WithCancel(t.Context()) defer cancel() done := make(chan error, 1) go func() { done <- schedules.Run(ctx, loop) }() synctest.Wait() require.Zero(t, loop.memory.Len()) clock.Set(clock.Now().Add(5 * time.Minute)) time.Sleep(scheduleClockWaitTimeout) synctest.Wait() require.Equal(t, 1, loop.memory.Len()) require.Contains(t, events.Last("work"), "Check the clock.") cancel() require.NoError(t, <-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}`)) } func TestSchedulesKeepTheirOwnerAcrossRestart(t *testing.T) { path := filepath.Join(t.TempDir(), scheduleFileName) clock := newTestClock() schedules, err := openSchedules(path, "GreenOtter", clock.Now, discardLog()) require.NoError(t, err) _, err = schedules.Set("Check the plants.", new(clock.Now().Add(time.Minute).Format(scheduleAtLayout)), nil) require.NoError(t, err) reopened, err := openSchedules(path, "GreenOtter", clock.Now, discardLog()) require.NoError(t, err) require.Equal(t, "GreenOtter", reopened.sorted()[0].Owner) _, err = openSchedules(path, "OneChat", clock.Now, discardLog()) require.ErrorContains(t, err, "belongs to") } func TestSchedulesAssignTheOwnerToSavedRootSchedules(t *testing.T) { path := filepath.Join(t.TempDir(), scheduleFileName) require.NoError(t, os.WriteFile(path, []byte(`{"next":1,"schedules":[{"id":1,"text":"Check the news.","due":"2026-11-09T09:00:00-03:00"}]}`), 0o600)) schedules, err := openSchedules(path, "OneChat", newTestClock().Now, discardLog()) require.NoError(t, err) require.Equal(t, "OneChat", schedules.sorted()[0].Owner) data, err := os.ReadFile(path) require.NoError(t, err) require.Contains(t, string(data), `"owner":"OneChat"`) } // 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 } // scheduleTestOpen opens the schedules of a new file, with a clock at Sunday // 2026-11-08 20:00 -03:00, and returns them and the path of the file. func scheduleTestOpen(t *testing.T) (*Schedules, string) { path := filepath.Join(t.TempDir(), scheduleFileName) clock := &TestClock{ now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)), } schedules, err := openSchedules(path, "OneChat", clock.Now, discardLog()) require.NoError(t, err) return schedules, path } // scheduleTestRun runs schedules in a new turn loop that calls server, until // the test ends. func scheduleTestRun(t *testing.T, schedules *Schedules, server *ModelFake) *TurnTestLoop { t.Helper() test := newTestTurnLoop(t, server) ctx, cancel := context.WithCancel(t.Context()) done := make(chan error) go func() { done <- schedules.Run(ctx, test.loop) }() t.Cleanup(func() { cancel() require.NoError(t, <-done) }) return test }