feat: import agents_and_robots platform as unibots (Matrix-out, unibus transport)
Reemplaza el scaffold del echobot por la plataforma completa de bots traida desde ~/DataProyects/Github/agents_and_robots tras la operacion Matrix-out: los bots ya no hablan por Matrix sino por el bus unibus (modelo todo-rooms + E2E via shell/transportunibus sobre github.com/enmanuel/unibus/pkg/client). - go.mod: replace de unibus -> ../unibus y de fn-registry -> ../../../.. (paths relativos reajustados a la nueva ubicacion dentro de fn_registry). - app.md: bump a 0.2.0, descripcion + arquitectura + comandos + gotchas reales. - modulo Go conservado como github.com/enmanuel/agents (sin reescribir imports). agents_and_robots queda archivado como museo de la era Matrix.
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user