package agents import ( "encoding/json" "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/mudler/LocalAI/core/services/testutil" ) // mockPublisher records all Publish calls for assertions. type mockPublisher struct { calls []publishCall } type publishCall struct { subject string data any } func (m *mockPublisher) Publish(subject string, data any) error { m.calls = append(m.calls, publishCall{subject: subject, data: data}) return nil } // mockSchedulerStore implements SchedulerStore for testing. type mockSchedulerStore struct { configs []AgentConfigRecord lastRunErr error updated []lastRunUpdate } type lastRunUpdate struct { userID string name string } func (m *mockSchedulerStore) ListConfigs(userID string) ([]AgentConfigRecord, error) { if userID == "" { return m.configs, nil } var filtered []AgentConfigRecord for _, c := range m.configs { if c.UserID == userID { filtered = append(filtered, c) } } return filtered, nil } func (m *mockSchedulerStore) UpdateLastRun(userID, name string) error { m.updated = append(m.updated, lastRunUpdate{userID: userID, name: name}) return m.lastRunErr } var _ = Describe("AgentScheduler", func() { // ----------------------------------------------------------------------- // parseInterval // ----------------------------------------------------------------------- Describe("parseInterval", func() { It("returns 10m for empty string", func() { Expect(parseInterval("")).To(Equal(10 * time.Minute)) }) It("parses valid duration strings", func() { Expect(parseInterval("5m")).To(Equal(5 * time.Minute)) Expect(parseInterval("1h")).To(Equal(1 * time.Hour)) Expect(parseInterval("30s")).To(Equal(30 * time.Second)) Expect(parseInterval("2h30m")).To(Equal(2*time.Hour + 30*time.Minute)) }) It("returns 10m for invalid strings", func() { Expect(parseInterval("not-a-duration")).To(Equal(10 * time.Minute)) Expect(parseInterval("abc")).To(Equal(10 * time.Minute)) }) It("returns 10m for zero duration", func() { Expect(parseInterval("0s")).To(Equal(10 * time.Minute)) }) It("returns 10m for negative duration", func() { Expect(parseInterval("-5m")).To(Equal(10 * time.Minute)) }) }) // ----------------------------------------------------------------------- // isDue (via IsDueExported) // ----------------------------------------------------------------------- Describe("isDue", func() { It("returns true when lastRun is nil", func() { Expect(IsDueExported(nil, 10*time.Minute)).To(BeTrue()) }) It("returns true when enough time has elapsed", func() { past := time.Now().Add(-15 * time.Minute) Expect(IsDueExported(&past, 10*time.Minute)).To(BeTrue()) }) It("returns false when not enough time has elapsed", func() { recent := time.Now().Add(-3 * time.Minute) Expect(IsDueExported(&recent, 10*time.Minute)).To(BeFalse()) }) It("returns true when exactly at the interval boundary", func() { exactly := time.Now().Add(-10 * time.Minute) Expect(IsDueExported(&exactly, 10*time.Minute)).To(BeTrue()) }) It("returns true with zero interval (always due)", func() { recent := time.Now().Add(-1 * time.Second) Expect(IsDueExported(&recent, 0)).To(BeTrue()) }) }) // ----------------------------------------------------------------------- // runDueAgents — test the scheduling logic with mocks // ----------------------------------------------------------------------- Describe("runDueAgents", func() { var ( pub *mockPublisher mStore *mockSchedulerStore sched *AgentScheduler ) BeforeEach(func() { db := testutil.SetupTestDB() pub = &mockPublisher{} mStore = &mockSchedulerStore{} sched = NewAgentScheduler(db, pub, mStore, "agent.execute") }) It("publishes event for a due standalone agent", func() { past := time.Now().Add(-15 * time.Minute) cfg := AgentConfig{ StandaloneJob: true, PeriodicRuns: "10m", Model: "gpt-4", } cfgJSON, err := json.Marshal(cfg) Expect(err).ToNot(HaveOccurred()) mStore.configs = []AgentConfigRecord{ { ID: "rec-1", UserID: "user-1", Name: "background-agent", ConfigJSON: string(cfgJSON), Status: StatusActive, LastRunAt: &past, }, } sched.runDueAgents() Expect(pub.calls).To(HaveLen(1)) Expect(pub.calls[0].subject).To(Equal("agent.execute")) evt, ok := pub.calls[0].data.(AgentChatEvent) Expect(ok).To(BeTrue()) Expect(evt.AgentName).To(Equal("background-agent")) Expect(evt.UserID).To(Equal("user-1")) Expect(evt.Role).To(Equal(RoleSystem)) Expect(evt.Config).ToNot(BeNil()) Expect(evt.Config.Model).To(Equal("gpt-4")) }) It("skips agents that are not due yet", func() { recent := time.Now().Add(-2 * time.Minute) cfg := AgentConfig{ StandaloneJob: true, PeriodicRuns: "10m", } cfgJSON, _ := json.Marshal(cfg) mStore.configs = []AgentConfigRecord{ { ID: "rec-2", UserID: "user-1", Name: "not-yet-agent", ConfigJSON: string(cfgJSON), Status: StatusActive, LastRunAt: &recent, }, } sched.runDueAgents() Expect(pub.calls).To(BeEmpty()) }) It("skips non-standalone agents", func() { past := time.Now().Add(-15 * time.Minute) cfg := AgentConfig{ StandaloneJob: false, PeriodicRuns: "10m", } cfgJSON, _ := json.Marshal(cfg) mStore.configs = []AgentConfigRecord{ { ID: "rec-3", UserID: "user-1", Name: "interactive-agent", ConfigJSON: string(cfgJSON), Status: StatusActive, LastRunAt: &past, }, } sched.runDueAgents() Expect(pub.calls).To(BeEmpty()) }) It("skips paused agents", func() { past := time.Now().Add(-15 * time.Minute) cfg := AgentConfig{ StandaloneJob: true, PeriodicRuns: "10m", } cfgJSON, _ := json.Marshal(cfg) mStore.configs = []AgentConfigRecord{ { ID: "rec-4", UserID: "user-1", Name: "paused-agent", ConfigJSON: string(cfgJSON), Status: StatusPaused, LastRunAt: &past, }, } sched.runDueAgents() Expect(pub.calls).To(BeEmpty()) }) It("skips agents with invalid config JSON", func() { past := time.Now().Add(-15 * time.Minute) mStore.configs = []AgentConfigRecord{ { ID: "rec-5", UserID: "user-1", Name: "broken-agent", ConfigJSON: `{invalid json`, Status: StatusActive, LastRunAt: &past, }, } sched.runDueAgents() Expect(pub.calls).To(BeEmpty()) }) It("updates last run timestamp after publishing", func() { cfg := AgentConfig{ StandaloneJob: true, PeriodicRuns: "10m", } cfgJSON, _ := json.Marshal(cfg) mStore.configs = []AgentConfigRecord{ { ID: "rec-6", UserID: "user-1", Name: "track-agent", ConfigJSON: string(cfgJSON), Status: StatusActive, LastRunAt: nil, // never run — due immediately }, } sched.runDueAgents() Expect(pub.calls).To(HaveLen(1)) Expect(mStore.updated).To(HaveLen(1)) Expect(mStore.updated[0].userID).To(Equal("user-1")) Expect(mStore.updated[0].name).To(Equal("track-agent")) }) It("enriches event with skills when skill provider is set", func() { cfg := AgentConfig{ StandaloneJob: true, PeriodicRuns: "10m", EnableSkills: true, } cfgJSON, _ := json.Marshal(cfg) mStore.configs = []AgentConfigRecord{ { ID: "rec-7", UserID: "user-1", Name: "skilled-agent", ConfigJSON: string(cfgJSON), Status: StatusActive, LastRunAt: nil, }, } skills := []SkillInfo{ {Name: "search", Description: "Search the web"}, {Name: "code", Description: "Write code"}, } provider := func(userID string) ([]SkillInfo, error) { return skills, nil } sched.skillProvider = provider sched.runDueAgents() Expect(pub.calls).To(HaveLen(1)) evt, ok := pub.calls[0].data.(AgentChatEvent) Expect(ok).To(BeTrue()) Expect(evt.Skills).To(HaveLen(2)) Expect(evt.Skills[0].Name).To(Equal("search")) Expect(evt.Skills[1].Name).To(Equal("code")) }) It("handles multiple agents in a single run", func() { past := time.Now().Add(-15 * time.Minute) cfg := AgentConfig{ StandaloneJob: true, PeriodicRuns: "10m", } cfgJSON, _ := json.Marshal(cfg) mStore.configs = []AgentConfigRecord{ { ID: "rec-a", UserID: "user-1", Name: "agent-a", ConfigJSON: string(cfgJSON), Status: StatusActive, LastRunAt: &past, }, { ID: "rec-b", UserID: "user-2", Name: "agent-b", ConfigJSON: string(cfgJSON), Status: StatusActive, LastRunAt: nil, // never run }, } sched.runDueAgents() Expect(pub.calls).To(HaveLen(2)) names := []string{ pub.calls[0].data.(AgentChatEvent).AgentName, pub.calls[1].data.(AgentChatEvent).AgentName, } Expect(names).To(ConsistOf("agent-a", "agent-b")) }) It("uses default interval when PeriodicRuns is empty", func() { // Default is 10m; set last run to 11 minutes ago => should be due past := time.Now().Add(-11 * time.Minute) cfg := AgentConfig{ StandaloneJob: true, PeriodicRuns: "", // empty => 10m default } cfgJSON, _ := json.Marshal(cfg) mStore.configs = []AgentConfigRecord{ { ID: "rec-8", UserID: "user-1", Name: "default-interval-agent", ConfigJSON: string(cfgJSON), Status: StatusActive, LastRunAt: &past, }, } sched.runDueAgents() Expect(pub.calls).To(HaveLen(1)) }) }) })