docs: crear issues 0036-0041 — nuevas features del sistema
Issues planificados: - 0036: Claude Code streaming de progreso en Matrix - 0037: Agente que crea otros agentes/bots via Matrix - 0038: Webapps y dashboards embebidos en Element via widgets - 0039: Recordatorios dinámicos y crons que invocan agentes - 0040: Soporte para mensajes de voz (audio → STT) - 0041: Videollamadas con agentes via LiveKit Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
package agents
|
||||
|
||||
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 <env>"},
|
||||
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 <env>",
|
||||
},
|
||||
func(_ context.Context, msgCtx decision.MessageContext) string {
|
||||
executed = true
|
||||
if len(msgCtx.Args) == 0 {
|
||||
return "Uso: !deploy <env>"
|
||||
}
|
||||
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 <env>"},
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user