test: tests para hot-reload (bus, registry, ciclo de vida del agente)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 18:43:46 +00:00
parent 8ec9f39b6d
commit f95370de80
3 changed files with 213 additions and 0 deletions
+64
View File
@@ -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()
}
+58
View File
@@ -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)
}
}
+91
View File
@@ -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)
}
}