package devagents import ( "context" "log/slog" "os" "strings" "testing" "time" "github.com/enmanuel/agents/internal/config" "github.com/enmanuel/agents/pkg/command" "github.com/enmanuel/agents/pkg/decision" ) // newTestRobot creates a minimal Robot for testing without requiring // Matrix or network. Fields are initialized directly. Uses standard "!" prefix. func newTestRobot(t *testing.T) *Robot { t.Helper() return newTestRobotWithPrefix(t, "!") } // newTestRobotNoPrefix creates a minimal Robot with command_prefix: "" (no prefix). func newTestRobotNoPrefix(t *testing.T) *Robot { t.Helper() return newTestRobotWithPrefix(t, "") } // newTestRobotWithPrefix creates a Robot with the given command prefix. func newTestRobotWithPrefix(t *testing.T, prefix string) *Robot { t.Helper() cfg := &config.AgentConfig{ Agent: config.AgentMeta{ ID: "test-robot", Name: "Test Robot", Type: "robot", Description: "robot for tests", Version: "1.0.0", }, Matrix: config.MatrixCfg{ Filters: config.FiltersCfg{ CommandPrefix: prefix, }, }, } r := &Robot{ cfg: cfg, logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), done: make(chan struct{}), commands: make(map[string]CommandHandler), cmdAliases: command.BuiltinNames(), startTime: time.Now(), } r.registerBuiltinCommands() return r } // TestRobotCmdHelp verifies !help lists built-in commands. func TestRobotCmdHelp(t *testing.T) { r := newTestRobot(t) reply := r.cmdHelp(context.Background(), decision.MessageContext{}) if !strings.Contains(reply, "Comandos disponibles") { t.Error("help reply missing header") } for _, cmd := range []string{"help", "ping", "status", "info", "version"} { if !strings.Contains(reply, "!"+cmd) { t.Errorf("help reply missing command !%s", cmd) } } // Robot should NOT show agent-only commands for _, cmd := range []string{"!tools", "!tool", "!clear", "!prompts"} { if strings.Contains(reply, cmd+"`") { t.Errorf("help reply should not contain agent-only command %s", cmd) } } } // TestRobotCmdHelpWithCustom verifies !help includes custom commands. func TestRobotCmdHelpWithCustom(t *testing.T) { r := newTestRobot(t) r.RegisterCommand( command.Spec{Name: "deploy", Description: "Deploy to env", Usage: "!deploy "}, func(_ context.Context, _ decision.MessageContext) string { return "deployed" }, ) reply := r.cmdHelp(context.Background(), decision.MessageContext{}) if !strings.Contains(reply, "Comandos del robot") { t.Error("help reply missing 'Comandos del robot' section") } if !strings.Contains(reply, "!deploy") { t.Error("help reply missing custom command !deploy") } } // TestRobotCmdPing verifies !ping returns pong. func TestRobotCmdPing(t *testing.T) { r := newTestRobot(t) reply := r.cmdPing(context.Background(), decision.MessageContext{}) if !strings.HasPrefix(reply, "pong") { t.Errorf("ping reply should start with 'pong', got %q", reply) } } // TestRobotCmdStatus verifies !status includes type and uptime. func TestRobotCmdStatus(t *testing.T) { r := newTestRobot(t) reply := r.cmdStatus(context.Background(), decision.MessageContext{}) if !strings.Contains(reply, "robot") { t.Error("status reply missing type 'robot'") } if !strings.Contains(reply, "Uptime") { t.Error("status reply missing Uptime") } } // TestRobotCmdInfo verifies !info shows robot identity. func TestRobotCmdInfo(t *testing.T) { r := newTestRobot(t) reply := r.cmdInfo(context.Background(), decision.MessageContext{}) if !strings.Contains(reply, "Test Robot") { t.Error("info reply missing robot name") } if !strings.Contains(reply, "test-robot") { t.Error("info reply missing robot ID") } if !strings.Contains(reply, "robot") { t.Error("info reply missing type 'robot'") } } // TestRobotCmdVersion verifies !version returns name + version. func TestRobotCmdVersion(t *testing.T) { r := newTestRobot(t) reply := r.cmdVersion(context.Background(), decision.MessageContext{}) if reply != "Test Robot 1.0.0" { t.Errorf("version reply = %q, want %q", reply, "Test Robot 1.0.0") } } // TestRobotIgnoresNonCommand verifies that handleEvent silently ignores // non-command messages (no error, no reply). func TestRobotIgnoresNonCommand(t *testing.T) { r := newTestRobot(t) // handleEvent with empty Command should not panic. // Since we can't easily mock the Matrix client, we verify the method // returns without error by checking it doesn't reach command dispatch. msgCtx := decision.MessageContext{ Command: "", // non-command Content: "hola bot", } // The robot should just return without doing anything. // We can't call handleEvent directly because it needs an *event.Event, // but we can verify the logic by checking the command map behavior. if _, ok := r.commands[""]; ok { t.Error("empty string should not be a registered command") } // Verify no commands match empty string. if _, ok := r.cmdAliases[""]; ok { t.Error("empty string should not be in aliases") } _ = msgCtx // used to document test intent } // TestRobotCustomCommand verifies RegisterCommand works and the handler executes. func TestRobotCustomCommand(t *testing.T) { r := newTestRobot(t) executed := false r.RegisterCommand( command.Spec{ Name: "deploy", Aliases: []string{"d"}, Description: "Deploy to env", Usage: "!deploy ", }, func(_ context.Context, msgCtx decision.MessageContext) string { executed = true if len(msgCtx.Args) == 0 { return "Uso: !deploy " } return "Deploying to " + msgCtx.Args[0] }, ) // Verify command is registered handler, ok := r.commands["deploy"] if !ok { t.Fatal("deploy command not registered") } // Execute the handler reply := handler(context.Background(), decision.MessageContext{ Command: "deploy", Args: []string{"staging"}, }) if !executed { t.Error("handler was not executed") } if reply != "Deploying to staging" { t.Errorf("reply = %q, want %q", reply, "Deploying to staging") } // Verify alias works canonical, ok := r.cmdAliases["d"] if !ok { t.Fatal("alias 'd' not registered") } if canonical != "deploy" { t.Errorf("alias canonical = %q, want %q", canonical, "deploy") } // Verify custom spec is tracked (for !help) if len(r.customSpecs) != 1 { t.Fatalf("customSpecs len = %d, want 1", len(r.customSpecs)) } if r.customSpecs[0].Name != "deploy" { t.Errorf("customSpecs[0].Name = %q, want %q", r.customSpecs[0].Name, "deploy") } } // TestRobotStopAndDone verifies lifecycle methods work correctly. func TestRobotStopAndDone(t *testing.T) { r := &Robot{ done: make(chan struct{}), } ctx, cancel := context.WithCancel(context.Background()) r.cancel = cancel started := make(chan struct{}) go func() { close(started) <-ctx.Done() close(r.done) }() <-started r.Stop() select { case <-r.Done(): // ok case <-time.After(2 * time.Second): t.Fatal("Done() did not close within 2s after Stop()") } } // TestRobotStopNilCancel verifies Stop is safe when cancel is nil. func TestRobotStopNilCancel(t *testing.T) { r := &Robot{ done: make(chan struct{}), } // cancel is nil — must not panic. r.Stop() } // TestRunnerInterfaceSatisfied verifies that both Agent and Robot // satisfy the Runner interface at compile time. func TestRunnerInterfaceSatisfied(t *testing.T) { // These are compile-time checks — if they compile, the test passes. var _ Runner = (*Agent)(nil) var _ Runner = (*Robot)(nil) } // TestRobotBuiltinCommandCount verifies the robot has exactly the expected // built-in commands and not more. func TestRobotBuiltinCommandCount(t *testing.T) { r := newTestRobot(t) expected := map[string]bool{ "help": true, "ping": true, "status": true, "info": true, "version": true, } for name := range r.commands { if !expected[name] { t.Errorf("unexpected built-in command %q in robot", name) } } for name := range expected { if _, ok := r.commands[name]; !ok { t.Errorf("missing built-in command %q in robot", name) } } } // ── No-prefix command tests ─────────────────────────────────────────────── // TestRobotNoPrefixCmdHelp verifies that help shows commands without ! prefix // when command_prefix is "". func TestRobotNoPrefixCmdHelp(t *testing.T) { r := newTestRobotNoPrefix(t) reply := r.cmdHelp(context.Background(), decision.MessageContext{}) if !strings.Contains(reply, "Comandos disponibles") { t.Error("help reply missing header") } // Commands should appear WITHOUT ! prefix for _, cmd := range []string{"help", "ping", "status", "info", "version"} { // Should contain the command name if !strings.Contains(reply, cmd) { t.Errorf("help reply missing command %s", cmd) } // Should NOT contain "!cmd" as usage (but might contain it elsewhere) if strings.Contains(reply, "!"+cmd) { t.Errorf("help reply should not show !%s in no-prefix mode", cmd) } } } // TestRobotNoPrefixCmdHelpWithCustom verifies custom commands in no-prefix mode. func TestRobotNoPrefixCmdHelpWithCustom(t *testing.T) { r := newTestRobotNoPrefix(t) r.RegisterCommand( command.Spec{Name: "deploy", Description: "Deploy to env", Usage: "deploy "}, func(_ context.Context, _ decision.MessageContext) string { return "deployed" }, ) reply := r.cmdHelp(context.Background(), decision.MessageContext{}) if !strings.Contains(reply, "Comandos del robot") { t.Error("help reply missing 'Comandos del robot' section") } if !strings.Contains(reply, "deploy") { t.Error("help reply missing custom command deploy") } } // TestRobotNoPrefixSameBuiltins verifies that no-prefix robots have the // same set of built-in commands as standard robots. func TestRobotNoPrefixSameBuiltins(t *testing.T) { standard := newTestRobot(t) noPrefix := newTestRobotNoPrefix(t) if len(standard.commands) != len(noPrefix.commands) { t.Errorf("command count mismatch: standard=%d, noPrefix=%d", len(standard.commands), len(noPrefix.commands)) } for name := range standard.commands { if _, ok := noPrefix.commands[name]; !ok { t.Errorf("no-prefix robot missing command %q", name) } } }