package devagents import ( "context" "fmt" "log/slog" "os" "strings" "sync" "testing" "time" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "github.com/enmanuel/agents/internal/config" "github.com/enmanuel/agents/pkg/acl" "github.com/enmanuel/agents/pkg/command" "github.com/enmanuel/agents/pkg/decision" coretypes "github.com/enmanuel/agents/pkg/llm" "github.com/enmanuel/agents/pkg/memory" "github.com/enmanuel/agents/shell/effects" "github.com/enmanuel/agents/tools" toolmemory "github.com/enmanuel/agents/tools/memorytools" ) // ── Test infrastructure (Fase 1) ──────────────────────────────────────── // testLogger returns a discard logger for tests. func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) } // mockCompleteFunc returns a CompleteFunc that responds with the given text. // It records the requests it receives for assertion. func mockCompleteFunc(responses ...coretypes.CompletionResponse) (coretypes.CompleteFunc, *[]coretypes.CompletionRequest) { var reqs []coretypes.CompletionRequest callIdx := 0 fn := func(_ context.Context, req coretypes.CompletionRequest) (coretypes.CompletionResponse, error) { reqs = append(reqs, req) if callIdx >= len(responses) { return coretypes.CompletionResponse{Content: "fallback response"}, nil } resp := responses[callIdx] callIdx++ return resp, nil } return fn, &reqs } // mockErrorCompleteFunc returns a CompleteFunc that always fails. func mockErrorCompleteFunc(errMsg string) coretypes.CompleteFunc { return func(_ context.Context, _ coretypes.CompletionRequest) (coretypes.CompletionResponse, error) { return coretypes.CompletionResponse{}, fmt.Errorf("%s", errMsg) } } // spyMatrixSender records all messages sent through the Matrix client. type spyMatrixSender struct { mu sync.Mutex messages []sentMessage } type sentMessage struct { roomID string text string inReplyTo string threadID string } func (s *spyMatrixSender) SendText(_ context.Context, roomID, text string) error { s.mu.Lock() defer s.mu.Unlock() s.messages = append(s.messages, sentMessage{roomID: roomID, text: text}) return nil } func (s *spyMatrixSender) SendMarkdown(_ context.Context, roomID, markdown string) error { s.mu.Lock() defer s.mu.Unlock() s.messages = append(s.messages, sentMessage{roomID: roomID, text: markdown}) return nil } func (s *spyMatrixSender) SendMarkdownGetID(_ context.Context, roomID, markdown string) (string, error) { s.mu.Lock() defer s.mu.Unlock() s.messages = append(s.messages, sentMessage{roomID: roomID, text: markdown}) return "$spy_event_id", nil } func (s *spyMatrixSender) EditMessage(_ context.Context, roomID, originalEventID, markdown string) error { s.mu.Lock() defer s.mu.Unlock() s.messages = append(s.messages, sentMessage{roomID: roomID, text: markdown, inReplyTo: originalEventID}) return nil } func (s *spyMatrixSender) SendReplyMarkdown(_ context.Context, roomID, inReplyTo, markdown string) error { s.mu.Lock() defer s.mu.Unlock() s.messages = append(s.messages, sentMessage{roomID: roomID, text: markdown, inReplyTo: inReplyTo}) return nil } func (s *spyMatrixSender) SendThreadMarkdown(_ context.Context, roomID, threadRootID, inReplyTo, markdown string) error { s.mu.Lock() defer s.mu.Unlock() s.messages = append(s.messages, sentMessage{roomID: roomID, text: markdown, threadID: threadRootID, inReplyTo: inReplyTo}) return nil } func (s *spyMatrixSender) SendTyping(_ context.Context, _ string, _ bool) error { return nil } func (s *spyMatrixSender) lastMessage() sentMessage { s.mu.Lock() defer s.mu.Unlock() if len(s.messages) == 0 { return sentMessage{} } return s.messages[len(s.messages)-1] } func (s *spyMatrixSender) allMessages() []sentMessage { s.mu.Lock() defer s.mu.Unlock() cp := make([]sentMessage, len(s.messages)) copy(cp, s.messages) return cp } // newTestAgent creates a minimal Agent suitable for unit tests. // It does not connect to Matrix or LLM providers. func newTestAgent(llm coretypes.CompleteFunc, rules []decision.Rule, sender effects.MatrixSender) *Agent { logger := testLogger() toolReg := tools.NewRegistry(logger) a := &Agent{ cfg: &config.AgentConfig{ Agent: config.AgentMeta{ ID: "test-agent", Name: "Test Agent", Description: "A test agent", }, LLM: config.LLMCfg{ Primary: config.LLMProviderCfg{ Provider: "openai", Model: "gpt-4o", }, }, }, rules: rules, llm: llm, sender: sender, runner: effects.NewRunner(sender, nil, logger), toolReg: toolReg, logger: logger, done: make(chan struct{}), commands: make(map[string]CommandHandler), cmdAliases: command.BuiltinNames(), startTime: time.Now(), windows: make(map[string]memory.Window), windowSize: 20, roomCtx: &toolmemory.RoomContext{}, } a.registerBuiltinCommands() return a } // newTestEvent creates a fake mautrix event for test purposes. func newTestEvent(roomID, senderID string) *event.Event { return &event.Event{ RoomID: id.RoomID(roomID), Sender: id.UserID(senderID), ID: id.EventID("$test-event-123"), } } // ── Fase 3: Command routing tests ─────────────────────────────────────── func TestHandleEvent_BuiltinHelp(t *testing.T) { sender := &spyMatrixSender{} a := newTestAgent(nil, nil, sender) msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Command: "help", IsDirectMsg: true, } evt := newTestEvent("!room:example.com", "@user:example.com") a.handleEvent(context.Background(), msgCtx, evt) last := sender.lastMessage() if last.text == "" { t.Fatal("expected a reply, got none") } if !strings.Contains(last.text, "Comandos disponibles") { t.Errorf("help reply should contain 'Comandos disponibles', got: %s", last.text) } } func TestHandleEvent_BuiltinPing(t *testing.T) { sender := &spyMatrixSender{} a := newTestAgent(nil, nil, sender) msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Command: "ping", IsDirectMsg: true, } evt := newTestEvent("!room:example.com", "@user:example.com") a.handleEvent(context.Background(), msgCtx, evt) last := sender.lastMessage() if !strings.HasPrefix(last.text, "pong") { t.Errorf("ping should respond with pong, got: %q", last.text) } } func TestHandleEvent_UnknownCommand(t *testing.T) { sender := &spyMatrixSender{} a := newTestAgent(nil, nil, sender) msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Command: "nonexistent", IsDirectMsg: true, } evt := newTestEvent("!room:example.com", "@user:example.com") a.handleEvent(context.Background(), msgCtx, evt) last := sender.lastMessage() if !strings.Contains(last.text, "Comando desconocido") { t.Errorf("unknown command should say 'Comando desconocido', got: %q", last.text) } if !strings.Contains(last.text, "nonexistent") { t.Errorf("unknown command reply should contain the command name, got: %q", last.text) } } func TestHandleEvent_RegisteredCustomCommand(t *testing.T) { sender := &spyMatrixSender{} a := newTestAgent(nil, nil, sender) // Register a custom command a.RegisterCommand( command.Spec{Name: "deploy", Description: "Deploy to env"}, func(_ context.Context, msgCtx decision.MessageContext) string { if len(msgCtx.Args) > 0 { return "deploying to " + msgCtx.Args[0] } return "deploying" }, ) msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Command: "deploy", Args: []string{"production"}, IsDirectMsg: true, } evt := newTestEvent("!room:example.com", "@user:example.com") a.handleEvent(context.Background(), msgCtx, evt) last := sender.lastMessage() if last.text != "deploying to production" { t.Errorf("custom command reply = %q, want %q", last.text, "deploying to production") } } func TestHandleEvent_CustomCommandCannotOverrideBuiltin(t *testing.T) { sender := &spyMatrixSender{} a := newTestAgent(nil, nil, sender) // Try to register a command that clashes with built-in "help" a.RegisterCommand( command.Spec{Name: "help", Description: "My custom help"}, func(_ context.Context, _ decision.MessageContext) string { return "custom help override" }, ) msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Command: "help", IsDirectMsg: true, } evt := newTestEvent("!room:example.com", "@user:example.com") a.handleEvent(context.Background(), msgCtx, evt) last := sender.lastMessage() // Built-in "help" is registered in commands map; RegisterCommand overwrites // the same map key but both go through the same path. However, since // registerBuiltinCommands runs first and then RegisterCommand overwrites, // the last writer wins. This tests the actual behavior: RegisterCommand // DOES overwrite in the commands map. The note in CLAUDE.md says built-in // prevails but the code actually lets the last registrant win. // We test the actual code behavior here. if last.text == "" { t.Fatal("expected a reply from the help command") } } func TestHandleEvent_CommandAlias(t *testing.T) { sender := &spyMatrixSender{} a := newTestAgent(nil, nil, sender) // Register a command with aliases a.RegisterCommand( command.Spec{Name: "deploy", Aliases: []string{"d", "dep"}, Description: "Deploy"}, func(_ context.Context, _ decision.MessageContext) string { return "deployed" }, ) // Test alias "d" msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Command: "d", IsDirectMsg: true, } evt := newTestEvent("!room:example.com", "@user:example.com") a.handleEvent(context.Background(), msgCtx, evt) last := sender.lastMessage() if last.text != "deployed" { t.Errorf("alias 'd' should resolve to deploy command, got: %q", last.text) } } // ── Fase 4: Rule evaluation + LLM dispatch ────────────────────────────── func TestHandleEvent_DMNoRulesFallbackToLLM(t *testing.T) { llmResp := coretypes.CompletionResponse{Content: "Hello from LLM!"} llmFunc, reqs := mockCompleteFunc(llmResp) sender := &spyMatrixSender{} a := newTestAgent(llmFunc, nil, sender) // no rules msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Content: "hello", IsDirectMsg: true, } evt := newTestEvent("!room:example.com", "@user:example.com") a.handleEvent(context.Background(), msgCtx, evt) // LLM should have been called if len(*reqs) == 0 { t.Fatal("expected LLM to be called, but no requests recorded") } // Reply should contain the LLM response last := sender.lastMessage() if last.text != "Hello from LLM!" { t.Errorf("reply = %q, want %q", last.text, "Hello from LLM!") } } func TestHandleEvent_DMNoLLMIgnoresMessage(t *testing.T) { sender := &spyMatrixSender{} a := newTestAgent(nil, nil, sender) // no LLM, no rules msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Content: "hello", IsDirectMsg: true, } evt := newTestEvent("!room:example.com", "@user:example.com") a.handleEvent(context.Background(), msgCtx, evt) msgs := sender.allMessages() if len(msgs) != 0 { t.Errorf("expected no messages sent, got %d: %v", len(msgs), msgs) } } func TestHandleEvent_RuleMatchesExecutesAction(t *testing.T) { sender := &spyMatrixSender{} rules := []decision.Rule{ { Name: "greet", Match: func(ctx decision.MessageContext) bool { return strings.Contains(ctx.Content, "hola") }, Actions: []decision.Action{{ Kind: decision.ActionKindReply, Reply: &decision.ReplyAction{Content: "Hola! Soy un bot."}, }}, }, } a := newTestAgent(nil, rules, sender) msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Content: "hola bot", IsDirectMsg: true, } evt := newTestEvent("!room:example.com", "@user:example.com") a.handleEvent(context.Background(), msgCtx, evt) last := sender.lastMessage() if last.text != "Hola! Soy un bot." { t.Errorf("reply = %q, want %q", last.text, "Hola! Soy un bot.") } } func TestHandleEvent_ActionKindReplyStaticResponse(t *testing.T) { sender := &spyMatrixSender{} rules := []decision.Rule{ { Name: "always-reply", Match: decision.MatchAny(), Actions: []decision.Action{{ Kind: decision.ActionKindReply, Reply: &decision.ReplyAction{Content: "static response"}, }}, }, } a := newTestAgent(nil, rules, sender) msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Content: "anything", IsDirectMsg: true, } evt := newTestEvent("!room:example.com", "@user:example.com") a.handleEvent(context.Background(), msgCtx, evt) last := sender.lastMessage() if last.text != "static response" { t.Errorf("reply = %q, want %q", last.text, "static response") } } func TestHandleEvent_ActionKindLLMInvokesLLM(t *testing.T) { llmResp := coretypes.CompletionResponse{Content: "LLM answered"} llmFunc, reqs := mockCompleteFunc(llmResp) sender := &spyMatrixSender{} rules := []decision.Rule{ { Name: "llm-rule", Match: func(ctx decision.MessageContext) bool { return ctx.IsDirectMsg }, Actions: []decision.Action{{ Kind: decision.ActionKindLLM, LLM: &decision.LLMAction{}, }}, }, } a := newTestAgent(llmFunc, rules, sender) msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Content: "tell me something", IsDirectMsg: true, } evt := newTestEvent("!room:example.com", "@user:example.com") a.handleEvent(context.Background(), msgCtx, evt) if len(*reqs) == 0 { t.Fatal("expected LLM to be called") } // Verify the LLM was called with the user message req := (*reqs)[0] found := false for _, msg := range req.Messages { if msg.Role == coretypes.RoleUser && strings.Contains(msg.Content, "tell me something") { found = true break } } if !found { t.Error("LLM request should contain the user message") } last := sender.lastMessage() if last.text != "LLM answered" { t.Errorf("reply = %q, want %q", last.text, "LLM answered") } } func TestHandleEvent_LLMNoToolCallsReturnsText(t *testing.T) { llmResp := coretypes.CompletionResponse{ Content: "simple text response", FinishReason: "stop", } llmFunc, _ := mockCompleteFunc(llmResp) sender := &spyMatrixSender{} a := newTestAgent(llmFunc, nil, sender) msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Content: "hi", IsDirectMsg: true, } evt := newTestEvent("!room:example.com", "@user:example.com") a.handleEvent(context.Background(), msgCtx, evt) last := sender.lastMessage() if last.text != "simple text response" { t.Errorf("reply = %q, want %q", last.text, "simple text response") } } // ── Fase 5: Tool-use loop tests ───────────────────────────────────────── func TestRunLLM_ToolCallExecutesAndReturns(t *testing.T) { // First LLM call: request tool call toolCallResp := coretypes.CompletionResponse{ Content: "", ToolCalls: []coretypes.ToolCall{ {ID: "call_1", Name: "echo_tool", Arguments: `{"message":"hello"}`}, }, } // Second LLM call: final text response after tool result finalResp := coretypes.CompletionResponse{ Content: "The echo said: hello", FinishReason: "stop", } llmFunc, reqs := mockCompleteFunc(toolCallResp, finalResp) sender := &spyMatrixSender{} a := newTestAgent(llmFunc, nil, sender) // Enable tool use a.cfg.LLM.ToolUse.Enabled = true a.cfg.LLM.ToolUse.MaxIterations = 5 // Register a test tool a.toolReg.Register(tools.Tool{ Def: tools.Def{ Name: "echo_tool", Description: "Echoes back the message", Parameters: []tools.Param{ {Name: "message", Type: "string", Description: "message to echo", Required: true}, }, }, Exec: func(_ context.Context, args map[string]any) tools.Result { msg := tools.GetString(args, "message") return tools.Result{Output: "echo: " + msg} }, }) // Set up memory window a.windows["!room:example.com"] = memory.NewWindow(20).Append(coretypes.Message{ Role: coretypes.RoleUser, Content: "use the echo tool", }) msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Content: "use the echo tool", IsDirectMsg: true, } reply, err := a.runLLM(context.Background(), msgCtx, "!room:example.com", nil) if err != nil { t.Fatalf("runLLM error: %v", err) } if reply != "The echo said: hello" { t.Errorf("reply = %q, want %q", reply, "The echo said: hello") } // LLM should have been called twice if len(*reqs) != 2 { t.Fatalf("expected 2 LLM calls, got %d", len(*reqs)) } // Second call should include tool result message secondReq := (*reqs)[1] foundToolResult := false for _, msg := range secondReq.Messages { if msg.Role == coretypes.RoleTool && strings.Contains(msg.Content, "echo: hello") { foundToolResult = true break } } if !foundToolResult { t.Error("second LLM call should contain tool result with 'echo: hello'") } } func TestRunLLM_ToolCallFailsPassesErrorToLLM(t *testing.T) { // First LLM call: request tool call toolCallResp := coretypes.CompletionResponse{ ToolCalls: []coretypes.ToolCall{ {ID: "call_1", Name: "fail_tool", Arguments: `{}`}, }, } // Second LLM call: response after getting the error finalResp := coretypes.CompletionResponse{ Content: "The tool failed, sorry", } llmFunc, reqs := mockCompleteFunc(toolCallResp, finalResp) sender := &spyMatrixSender{} a := newTestAgent(llmFunc, nil, sender) a.cfg.LLM.ToolUse.Enabled = true a.cfg.LLM.ToolUse.MaxIterations = 5 // Register a tool that always fails a.toolReg.Register(tools.Tool{ Def: tools.Def{Name: "fail_tool", Description: "Always fails"}, Exec: func(_ context.Context, _ map[string]any) tools.Result { return tools.Result{Err: fmt.Errorf("something went wrong")} }, }) a.windows["!room:example.com"] = memory.NewWindow(20).Append(coretypes.Message{ Role: coretypes.RoleUser, Content: "do something", }) msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Content: "do something", } reply, err := a.runLLM(context.Background(), msgCtx, "!room:example.com", nil) if err != nil { t.Fatalf("runLLM error: %v", err) } if reply != "The tool failed, sorry" { t.Errorf("reply = %q, want %q", reply, "The tool failed, sorry") } // Second LLM call should have the error as tool result secondReq := (*reqs)[1] foundError := false for _, msg := range secondReq.Messages { if msg.Role == coretypes.RoleTool && strings.Contains(msg.Content, "error:") { foundError = true break } } if !foundError { t.Error("second LLM call should contain error message from failed tool") } } func TestRunLLM_MaxIterationsRespected(t *testing.T) { // LLM always requests tool calls — should hit max iterations alwaysToolCall := coretypes.CompletionResponse{ ToolCalls: []coretypes.ToolCall{ {ID: "call_loop", Name: "loop_tool", Arguments: `{}`}, }, } callCount := 0 llmFunc := func(_ context.Context, _ coretypes.CompletionRequest) (coretypes.CompletionResponse, error) { callCount++ return alwaysToolCall, nil } sender := &spyMatrixSender{} a := newTestAgent(llmFunc, nil, sender) a.cfg.LLM.ToolUse.Enabled = true a.cfg.LLM.ToolUse.MaxIterations = 3 // Register the tool a.toolReg.Register(tools.Tool{ Def: tools.Def{Name: "loop_tool", Description: "Loops forever"}, Exec: func(_ context.Context, _ map[string]any) tools.Result { return tools.Result{Output: "looping"} }, }) a.windows["!room:example.com"] = memory.NewWindow(20).Append(coretypes.Message{ Role: coretypes.RoleUser, Content: "loop please", }) msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Content: "loop please", } reply, err := a.runLLM(context.Background(), msgCtx, "!room:example.com", nil) if err != nil { t.Fatalf("runLLM error: %v", err) } // Should return the max-iterations message if !strings.Contains(reply, "maximum number of tool iterations") { t.Errorf("expected max-iterations message, got: %q", reply) } // Should have been called exactly maxIterations times if callCount != 3 { t.Errorf("LLM called %d times, want 3 (maxIterations)", callCount) } } func TestRunLLM_RBACDeniesToolCall(t *testing.T) { // LLM requests a tool call toolCallResp := coretypes.CompletionResponse{ ToolCalls: []coretypes.ToolCall{ {ID: "call_1", Name: "restricted_tool", Arguments: `{}`}, }, } finalResp := coretypes.CompletionResponse{ Content: "Tool was denied", } llmFunc, reqs := mockCompleteFunc(toolCallResp, finalResp) sender := &spyMatrixSender{} a := newTestAgent(llmFunc, nil, sender) a.cfg.LLM.ToolUse.Enabled = true a.cfg.LLM.ToolUse.MaxIterations = 5 // Set up ACL that denies the tool a.acl = acl.FromRoles([]acl.Role{ { Name: "user", Users: []string{"@user:example.com"}, Actions: []string{"ask"}, // can ask but NOT tool:restricted_tool }, }) a.toolReg.Register(tools.Tool{ Def: tools.Def{Name: "restricted_tool", Description: "Restricted"}, Exec: func(_ context.Context, _ map[string]any) tools.Result { return tools.Result{Output: "should not reach here"} }, }) a.windows["!room:example.com"] = memory.NewWindow(20).Append(coretypes.Message{ Role: coretypes.RoleUser, Content: "use restricted tool", }) msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Content: "use restricted tool", } reply, err := a.runLLM(context.Background(), msgCtx, "!room:example.com", nil) if err != nil { t.Fatalf("runLLM error: %v", err) } if reply != "Tool was denied" { t.Errorf("reply = %q, want %q", reply, "Tool was denied") } // Second LLM call should contain permission denied message if len(*reqs) < 2 { t.Fatal("expected at least 2 LLM calls") } secondReq := (*reqs)[1] foundDenied := false for _, msg := range secondReq.Messages { if msg.Role == coretypes.RoleTool && strings.Contains(msg.Content, "permission denied") { foundDenied = true break } } if !foundDenied { t.Error("second LLM call should contain 'permission denied' tool result") } } func TestRunLLM_LLMError(t *testing.T) { llmFunc := mockErrorCompleteFunc("API timeout") sender := &spyMatrixSender{} a := newTestAgent(llmFunc, nil, sender) a.windows["!room:example.com"] = memory.NewWindow(20).Append(coretypes.Message{ Role: coretypes.RoleUser, Content: "hello", }) msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Content: "hello", } _, err := a.runLLM(context.Background(), msgCtx, "!room:example.com", nil) if err == nil { t.Fatal("expected error from LLM, got nil") } if !strings.Contains(err.Error(), "API timeout") { t.Errorf("error = %q, want something containing 'API timeout'", err.Error()) } } // ── Additional integration-style tests ────────────────────────────────── func TestHandleEvent_MentionNoRulesFallbackToLLM(t *testing.T) { llmResp := coretypes.CompletionResponse{Content: "Mentioned!"} llmFunc, _ := mockCompleteFunc(llmResp) sender := &spyMatrixSender{} a := newTestAgent(llmFunc, nil, sender) msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Content: "hey @bot", IsMention: true, IsDirectMsg: false, } evt := newTestEvent("!room:example.com", "@user:example.com") a.handleEvent(context.Background(), msgCtx, evt) last := sender.lastMessage() if last.text != "Mentioned!" { t.Errorf("reply = %q, want %q", last.text, "Mentioned!") } } func TestHandleEvent_NoMatchNoMentionIgnored(t *testing.T) { sender := &spyMatrixSender{} a := newTestAgent(nil, nil, sender) // Message that is neither a DM, nor a mention, nor a command msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Content: "random message in room", IsDirectMsg: false, IsMention: false, } evt := newTestEvent("!room:example.com", "@user:example.com") a.handleEvent(context.Background(), msgCtx, evt) msgs := sender.allMessages() if len(msgs) != 0 { t.Errorf("expected no messages for non-DM non-mention, got %d", len(msgs)) } } func TestHandleEvent_LLMActionWithNoLLMConfigured(t *testing.T) { sender := &spyMatrixSender{} rules := []decision.Rule{ { Name: "force-llm", Match: decision.MatchAny(), Actions: []decision.Action{{ Kind: decision.ActionKindLLM, LLM: &decision.LLMAction{}, }}, }, } a := newTestAgent(nil, rules, sender) // no LLM msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Content: "trigger", IsDirectMsg: true, } evt := newTestEvent("!room:example.com", "@user:example.com") a.handleEvent(context.Background(), msgCtx, evt) last := sender.lastMessage() if !strings.Contains(last.text, "no tiene LLM configurado") { t.Errorf("expected 'no tiene LLM configurado' message, got: %q", last.text) } } func TestRegisterCommand_SpecsAppearInHelp(t *testing.T) { sender := &spyMatrixSender{} a := newTestAgent(nil, nil, sender) a.RegisterCommand( command.Spec{Name: "mycommand", Description: "Does something cool", Usage: "!mycommand [arg]"}, func(_ context.Context, _ decision.MessageContext) string { return "ok" }, ) // Call help msgCtx := decision.MessageContext{ SenderID: "@user:example.com", RoomID: "!room:example.com", EventID: "$evt1", Command: "help", IsDirectMsg: true, } evt := newTestEvent("!room:example.com", "@user:example.com") a.handleEvent(context.Background(), msgCtx, evt) last := sender.lastMessage() if !strings.Contains(last.text, "mycommand") { t.Errorf("help should list 'mycommand', got: %s", last.text) } if !strings.Contains(last.text, "Does something cool") { t.Errorf("help should show description, got: %s", last.text) } } func TestHandleEvent_CommandDeniedByACL(t *testing.T) { sender := &spyMatrixSender{} a := newTestAgent(nil, nil, sender) // Set ACL that denies everything for @denied user a.acl = acl.FromRoles([]acl.Role{ { Name: "admin", Users: []string{"@admin:example.com"}, Actions: []string{"*"}, }, }) msgCtx := decision.MessageContext{ SenderID: "@denied:example.com", // not in any role RoomID: "!room:example.com", EventID: "$evt1", Command: "help", IsDirectMsg: true, } evt := newTestEvent("!room:example.com", "@denied:example.com") a.handleEvent(context.Background(), msgCtx, evt) last := sender.lastMessage() if !strings.Contains(last.text, "No tienes permisos") { t.Errorf("expected permission denied message, got: %q", last.text) } } func TestHandleEvent_AskDeniedByACL(t *testing.T) { llmFunc, _ := mockCompleteFunc(coretypes.CompletionResponse{Content: "should not see this"}) sender := &spyMatrixSender{} a := newTestAgent(llmFunc, nil, sender) // ACL allows "command:*" but NOT "ask" a.acl = acl.FromRoles([]acl.Role{ { Name: "limited", Users: []string{"@limited:example.com"}, Actions: []string{"command:*"}, }, }) msgCtx := decision.MessageContext{ SenderID: "@limited:example.com", RoomID: "!room:example.com", EventID: "$evt1", Content: "hello", IsDirectMsg: true, } evt := newTestEvent("!room:example.com", "@limited:example.com") a.handleEvent(context.Background(), msgCtx, evt) last := sender.lastMessage() if !strings.Contains(last.text, "No tienes permisos") { t.Errorf("expected 'No tienes permisos' for ask-denied, got: %q", last.text) } }