From f95370de803e13fdb6307f19c1f30dba608bb142 Mon Sep 17 00:00:00 2001 From: Enmanuel Date: Sun, 8 Mar 2026 18:43:46 +0000 Subject: [PATCH] test: tests para hot-reload (bus, registry, ciclo de vida del agente) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shell/bus/bus_test.go: tests de Subscribe/Send/Unsubscribe incluyendo idempotencia, canal cerrado tras unsubscribe y resubscribe posterior. - cmd/launcher/registry_test.go: tests para readReloadTarget (archivo ausente, vacío, '*', agentID, whitespace). - agents/lifecycle_test.go: tests para Agent.Stop()/Done() verificando que Stop() desbloquea Run y que es seguro llamarlo múltiples veces o con cancel nil. Co-Authored-By: Claude Sonnet 4.6 --- agents/lifecycle_test.go | 64 ++++++++++++++++++++++++ cmd/launcher/registry_test.go | 58 ++++++++++++++++++++++ shell/bus/bus_test.go | 91 +++++++++++++++++++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 agents/lifecycle_test.go create mode 100644 cmd/launcher/registry_test.go create mode 100644 shell/bus/bus_test.go diff --git a/agents/lifecycle_test.go b/agents/lifecycle_test.go new file mode 100644 index 0000000..93c71b2 --- /dev/null +++ b/agents/lifecycle_test.go @@ -0,0 +1,64 @@ +package agents + +import ( + "context" + "testing" + "time" +) + +// TestAgentStopAndDone verifies that Stop() cancels Run and Done() closes. +// Uses a minimal Agent (no Matrix, no LLM) via direct struct init so the test +// doesn't require network or external dependencies. +func TestAgentStopAndDone(t *testing.T) { + a := &Agent{ + done: make(chan struct{}), + } + + // Simulate Run: create the cancel, then immediately block on ctx. + ctx, cancel := context.WithCancel(context.Background()) + a.cancel = cancel + + started := make(chan struct{}) + go func() { + close(started) + // Mimic what Run does: block on ctx, then close done. + <-ctx.Done() + close(a.done) + }() + + <-started + + // Stop must unblock the goroutine above. + a.Stop() + + select { + case <-a.Done(): + // ok + case <-time.After(2 * time.Second): + t.Fatal("Done() did not close within 2s after Stop()") + } +} + +// TestAgentStopIdempotent verifies that calling Stop() multiple times is safe. +func TestAgentStopIdempotent(t *testing.T) { + a := &Agent{ + done: make(chan struct{}), + } + _, cancel := context.WithCancel(context.Background()) + a.cancel = cancel + defer cancel() + + // Should not panic when called multiple times. + a.Stop() + a.Stop() + a.Stop() +} + +// TestAgentStopNilCancel verifies Stop() is safe when cancel is nil. +func TestAgentStopNilCancel(t *testing.T) { + a := &Agent{ + done: make(chan struct{}), + } + // cancel is nil — must not panic. + a.Stop() +} diff --git a/cmd/launcher/registry_test.go b/cmd/launcher/registry_test.go new file mode 100644 index 0000000..83a6131 --- /dev/null +++ b/cmd/launcher/registry_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadReloadTarget_missing(t *testing.T) { + got := readReloadTarget(filepath.Join(t.TempDir(), "reload.txt")) + if got != "" { + t.Fatalf("expected empty string for missing file, got %q", got) + } +} + +func TestReadReloadTarget_empty(t *testing.T) { + f := filepath.Join(t.TempDir(), "reload.txt") + if err := os.WriteFile(f, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + got := readReloadTarget(f) + if got != "" { + t.Fatalf("expected empty string for empty file, got %q", got) + } +} + +func TestReadReloadTarget_star(t *testing.T) { + f := filepath.Join(t.TempDir(), "reload.txt") + if err := os.WriteFile(f, []byte("*\n"), 0o644); err != nil { + t.Fatal(err) + } + got := readReloadTarget(f) + if got != "" { + t.Fatalf("expected empty string for '*', got %q", got) + } +} + +func TestReadReloadTarget_agentID(t *testing.T) { + f := filepath.Join(t.TempDir(), "reload.txt") + if err := os.WriteFile(f, []byte("assistant-bot\n"), 0o644); err != nil { + t.Fatal(err) + } + got := readReloadTarget(f) + if got != "assistant-bot" { + t.Fatalf("expected 'assistant-bot', got %q", got) + } +} + +func TestReadReloadTarget_whitespace(t *testing.T) { + f := filepath.Join(t.TempDir(), "reload.txt") + if err := os.WriteFile(f, []byte(" asistente-2 \n"), 0o644); err != nil { + t.Fatal(err) + } + got := readReloadTarget(f) + if got != "asistente-2" { + t.Fatalf("expected 'asistente-2', got %q", got) + } +} diff --git a/shell/bus/bus_test.go b/shell/bus/bus_test.go new file mode 100644 index 0000000..5573f41 --- /dev/null +++ b/shell/bus/bus_test.go @@ -0,0 +1,91 @@ +package bus_test + +import ( + "log/slog" + "testing" + + "github.com/enmanuel/agents/shell/bus" +) + +func newBus() *bus.Bus { + return bus.New(slog.Default()) +} + +func TestSubscribeAndSend(t *testing.T) { + b := newBus() + ch := b.Subscribe("agent-a") + + msg := bus.AgentMessage{From: "orch", To: "agent-a", Kind: bus.KindTask, Payload: map[string]string{"k": "v"}} + if err := b.Send(msg); err != nil { + t.Fatalf("Send: %v", err) + } + + got := <-ch + if got.Kind != bus.KindTask || got.Payload["k"] != "v" { + t.Fatalf("unexpected message: %+v", got) + } +} + +func TestUnsubscribeClosesChannel(t *testing.T) { + b := newBus() + ch := b.Subscribe("agent-b") + + b.Unsubscribe("agent-b") + + // Channel must be closed — reading from a closed channel returns zero value + ok=false. + _, ok := <-ch + if ok { + t.Fatal("expected channel to be closed after Unsubscribe") + } +} + +func TestUnsubscribeRemovesFromBus(t *testing.T) { + b := newBus() + b.Subscribe("agent-c") + b.Unsubscribe("agent-c") + + // Sending after unsubscribe must return an error, not panic. + err := b.Send(bus.AgentMessage{To: "agent-c", Kind: "ping"}) + if err == nil { + t.Fatal("expected error when sending to unsubscribed agent") + } +} + +func TestUnsubscribeIdempotent(t *testing.T) { + b := newBus() + b.Subscribe("agent-d") + // Double unsubscribe must not panic. + b.Unsubscribe("agent-d") + b.Unsubscribe("agent-d") +} + +func TestUnsubscribeNonExistent(t *testing.T) { + b := newBus() + // Unsubscribing an ID that was never subscribed must not panic. + b.Unsubscribe("does-not-exist") +} + +func TestSendToUnknownAgent(t *testing.T) { + b := newBus() + err := b.Send(bus.AgentMessage{To: "ghost", Kind: "hello"}) + if err == nil { + t.Fatal("expected error when sending to unknown agent") + } +} + +func TestResubscribeAfterUnsubscribe(t *testing.T) { + b := newBus() + b.Subscribe("agent-e") + b.Unsubscribe("agent-e") + + // Re-subscribe must work and deliver messages. + ch2 := b.Subscribe("agent-e") + msg := bus.AgentMessage{To: "agent-e", Kind: "ping"} + if err := b.Send(msg); err != nil { + t.Fatalf("Send after re-subscribe: %v", err) + } + got := <-ch2 + if got.Kind != "ping" { + t.Fatalf("unexpected kind: %q", got.Kind) + } +}