Files
agents_and_robots/shell/cron/scheduler_test.go
T
egutierrez e481cb8783 feat: catálogo crons/ + scripts dev-scripts/cron/ + Fire() en scheduler
Implementa issue 0025: catálogo central de automatizaciones cron y scaffolder.

- crons/: directorio de automatizaciones nombradas con README explicando la
  convención. Incluye dos ejemplos listos para usar:
    · good-morning (send_message, 0 9 * * *) — saludo diario
    · daily-summary (llm_prompt, 0 18 * * *)  — resumen generado por LLM

- dev-scripts/cron/new.sh: scaffolder interactivo — pregunta nombre,
  descripción, tipo de acción y cron expression; crea schedule.yaml +
  archivo de prompt vacío; imprime el bloque YAML para copiar en config.yaml.

- dev-scripts/cron/list.sh: lista todas las automatizaciones del catálogo
  con nombre, tipo, cron y descripción en formato tabular.

- dev-scripts/cron/apply.sh: añade la automatización al config.yaml del
  agente indicado usando yq si está disponible; si no, imprime el bloque
  YAML para copiar a mano (sin dependencias obligatorias).

- shell/cron/scheduler.go: exporta Fire(ctx, sc) para disparo inmediato
  de un schedule sin esperar al timer cron — útil en tests y CLI.

- shell/cron/scheduler_test.go: cuatro tests nuevos para Fire()
  (send_message inline, llm_prompt, sin output_room, sin LLM).
  TestScheduler_SkipsInvalidSchedule y TestFire_LLMPrompt_NoLLM_Skips
  reemplazados por versiones instantáneas usando Fire en lugar de
  @every 100ms + sleep, eliminando ~700ms de tiempo de test.
2026-03-08 20:01:02 +00:00

327 lines
8.2 KiB
Go

package cron_test
import (
"context"
"errors"
"log/slog"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
"github.com/enmanuel/agents/internal/config"
coretypes "github.com/enmanuel/agents/pkg/llm"
shellcron "github.com/enmanuel/agents/shell/cron"
)
// ── fakes ──────────────────────────────────────────────────────────────────
type fakeSender struct {
calls atomic.Int32
lastMD string
lastRM string
}
func (f *fakeSender) SendMarkdown(_ context.Context, room, md string) error {
f.calls.Add(1)
f.lastRM = room
f.lastMD = md
return nil
}
type errSender struct{}
func (e *errSender) SendMarkdown(_ context.Context, _, _ string) error {
return errors.New("matrix unavailable")
}
func fakeLLM(reply string) coretypes.CompleteFunc {
return func(_ context.Context, _ coretypes.CompletionRequest) (coretypes.CompletionResponse, error) {
return coretypes.CompletionResponse{Content: reply}, nil
}
}
func newTestLogger(t *testing.T) *slog.Logger {
t.Helper()
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
}
// ── helpers ────────────────────────────────────────────────────────────────
// waitCalls blocks until the sender has received at least n calls or the deadline passes.
func waitCalls(t *testing.T, f *fakeSender, n int32) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if f.calls.Load() >= n {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("expected %d call(s) to SendMarkdown, got %d", n, f.calls.Load())
}
// ── cron-based tests (require timer) ──────────────────────────────────────
func TestScheduler_SendMessage_Inline(t *testing.T) {
sender := &fakeSender{}
cfg := []config.ScheduleCfg{
{
Name: "test-inline",
Cron: "@every 100ms",
OutputRoom: "!room:server.com",
Action: config.ScheduledAction{
Kind: "send_message",
Message: "hola mundo",
},
},
}
s := shellcron.New(cfg, sender, nil, "", newTestLogger(t))
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
s.Start(ctx)
}()
waitCalls(t, sender, 1)
cancel()
<-done
if sender.lastRM != "!room:server.com" {
t.Errorf("unexpected room: %s", sender.lastRM)
}
if sender.lastMD != "hola mundo" {
t.Errorf("unexpected message: %s", sender.lastMD)
}
}
func TestScheduler_SendMessage_Template(t *testing.T) {
// Write a temporary template file
dir := t.TempDir()
tmpl := filepath.Join(dir, "greeting.md")
if err := os.WriteFile(tmpl, []byte("buenos días"), 0o600); err != nil {
t.Fatal(err)
}
sender := &fakeSender{}
cfg := []config.ScheduleCfg{
{
Name: "test-template",
Cron: "@every 100ms",
OutputRoom: "!room2:server.com",
Action: config.ScheduledAction{
Kind: "send_message",
Template: tmpl,
},
},
}
s := shellcron.New(cfg, sender, nil, "", newTestLogger(t))
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
s.Start(ctx)
}()
waitCalls(t, sender, 1)
cancel()
<-done
if sender.lastMD != "buenos días" {
t.Errorf("unexpected message: %q", sender.lastMD)
}
}
func TestScheduler_LLMPrompt(t *testing.T) {
sender := &fakeSender{}
cfg := []config.ScheduleCfg{
{
Name: "test-llm",
Cron: "@every 100ms",
OutputRoom: "!room3:server.com",
Action: config.ScheduledAction{
Kind: "llm_prompt",
Prompt: "resume el día",
},
},
}
llm := fakeLLM("resumen generado por LLM")
s := shellcron.New(cfg, sender, llm, "gpt-4o", newTestLogger(t))
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
s.Start(ctx)
}()
waitCalls(t, sender, 1)
cancel()
<-done
if sender.lastMD != "resumen generado por LLM" {
t.Errorf("unexpected LLM reply: %q", sender.lastMD)
}
}
func TestScheduler_MatrixSendError(t *testing.T) {
// If matrix.SendMarkdown returns an error, the scheduler should log it and not panic.
cfg := []config.ScheduleCfg{
{
Name: "err-send",
Cron: "@every 100ms",
OutputRoom: "!room:server.com",
Action: config.ScheduledAction{
Kind: "send_message",
Message: "trigger error",
},
},
}
s := shellcron.New(cfg, &errSender{}, nil, "", newTestLogger(t))
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
s.Start(ctx)
}()
// Let it fire at least once without panicking
time.Sleep(250 * time.Millisecond)
cancel()
<-done
}
// ── Fire() tests (deterministic, no timer) ─────────────────────────────────
func TestFire_SendMessage_Inline(t *testing.T) {
sender := &fakeSender{}
s := shellcron.New(nil, sender, nil, "", newTestLogger(t))
sc := config.ScheduleCfg{
Name: "fire-inline",
Cron: "0 9 * * *",
OutputRoom: "!fireroom:server.com",
Action: config.ScheduledAction{
Kind: "send_message",
Message: "buenos días via Fire",
},
}
s.Fire(context.Background(), sc)
if sender.calls.Load() != 1 {
t.Fatalf("expected 1 call, got %d", sender.calls.Load())
}
if sender.lastRM != "!fireroom:server.com" {
t.Errorf("unexpected room: %s", sender.lastRM)
}
if sender.lastMD != "buenos días via Fire" {
t.Errorf("unexpected message: %s", sender.lastMD)
}
}
func TestFire_LLMPrompt(t *testing.T) {
sender := &fakeSender{}
llm := fakeLLM("respuesta del LLM via Fire")
s := shellcron.New(nil, sender, llm, "gpt-4o", newTestLogger(t))
sc := config.ScheduleCfg{
Name: "fire-llm",
Cron: "0 18 * * *",
OutputRoom: "!llmroom:server.com",
Action: config.ScheduledAction{
Kind: "llm_prompt",
Prompt: "resume el día",
},
}
s.Fire(context.Background(), sc)
if sender.calls.Load() != 1 {
t.Fatalf("expected 1 call, got %d", sender.calls.Load())
}
if sender.lastMD != "respuesta del LLM via Fire" {
t.Errorf("unexpected LLM reply: %q", sender.lastMD)
}
}
func TestFire_NoOutputRoom_Skips(t *testing.T) {
sender := &fakeSender{}
s := shellcron.New(nil, sender, nil, "", newTestLogger(t))
sc := config.ScheduleCfg{
Name: "fire-no-room",
Cron: "0 9 * * *",
OutputRoom: "", // intentionally empty
Action: config.ScheduledAction{
Kind: "send_message",
Message: "should not send",
},
}
s.Fire(context.Background(), sc)
if sender.calls.Load() != 0 {
t.Errorf("expected 0 calls when output_room is empty, got %d", sender.calls.Load())
}
}
func TestFire_LLMPrompt_NoLLM_Skips(t *testing.T) {
// When no LLM is configured, Fire with llm_prompt should not send anything.
sender := &fakeSender{}
s := shellcron.New(nil, sender, nil, "", newTestLogger(t))
sc := config.ScheduleCfg{
Name: "fire-no-llm",
Cron: "0 9 * * *",
OutputRoom: "!room:server.com",
Action: config.ScheduledAction{
Kind: "llm_prompt",
Prompt: "hello",
},
}
s.Fire(context.Background(), sc)
if sender.calls.Load() != 0 {
t.Errorf("expected 0 calls without LLM, got %d", sender.calls.Load())
}
}
func TestScheduler_SkipsInvalidSchedule(t *testing.T) {
// Schedules without output_room or without action kind must be skipped during Start.
// We use Fire directly to test the skip logic without timer overhead.
sender := &fakeSender{}
s := shellcron.New(nil, sender, nil, "", newTestLogger(t))
ctx := context.Background()
// No output_room → skip
s.Fire(ctx, config.ScheduleCfg{
Name: "no-room",
Cron: "@every 100ms",
// missing OutputRoom
Action: config.ScheduledAction{Kind: "send_message", Message: "hi"},
})
// No kind → Fire calls buildHandler which returns nil → skip
s.Fire(ctx, config.ScheduleCfg{
Name: "no-kind",
Cron: "@every 100ms",
OutputRoom: "!room:server.com",
// missing Action.Kind
})
if sender.calls.Load() != 0 {
t.Errorf("expected 0 calls for invalid schedules, got %d", sender.calls.Load())
}
}