package effects import ( "context" "log/slog" "strings" "testing" "time" coretypes "github.com/enmanuel/agents/pkg/llm" ) // mockProgressSender records sends and edits for testing ProgressReporter. type mockProgressSender struct { fakeMatrixSender // embed to satisfy the full interface sends []string // markdowns from SendMarkdownGetID edits []string // markdowns from EditMessage editTargets []string // event IDs targeted by EditMessage } func (m *mockProgressSender) SendMarkdownGetID(_ context.Context, _, markdown string) (string, error) { m.sends = append(m.sends, markdown) return "$progress_msg_1", nil } func (m *mockProgressSender) EditMessage(_ context.Context, _, originalEventID, markdown string) error { m.edits = append(m.edits, markdown) m.editTargets = append(m.editTargets, originalEventID) return nil } func TestProgressReporter_InitEvent(t *testing.T) { sender := &mockProgressSender{} pr := NewProgressReporter(sender, "!room:test", slog.Default()) fn := pr.StreamFunc() fn(coretypes.StreamEvent{Kind: coretypes.StreamInit}) if len(sender.sends) != 1 { t.Fatalf("expected 1 send, got %d", len(sender.sends)) } if !strings.Contains(sender.sends[0], "Procesando") { t.Errorf("init message = %q, should contain 'Procesando'", sender.sends[0]) } if pr.EventID() != "$progress_msg_1" { t.Errorf("EventID = %q, want %q", pr.EventID(), "$progress_msg_1") } } func TestProgressReporter_ToolUseEditsMessage(t *testing.T) { sender := &mockProgressSender{} pr := NewProgressReporter(sender, "!room:test", slog.Default()) pr.minInterval = 0 // disable rate limiting for test fn := pr.StreamFunc() // First event creates the message fn(coretypes.StreamEvent{Kind: coretypes.StreamInit}) // Second event should edit fn(coretypes.StreamEvent{ Kind: coretypes.StreamToolUse, ToolName: "Bash", ToolInput: "ls -la", }) if len(sender.edits) != 1 { t.Fatalf("expected 1 edit, got %d", len(sender.edits)) } if !strings.Contains(sender.edits[0], "Bash") { t.Errorf("edit = %q, should contain tool name", sender.edits[0]) } if !strings.Contains(sender.edits[0], "ls -la") { t.Errorf("edit = %q, should contain tool input", sender.edits[0]) } if sender.editTargets[0] != "$progress_msg_1" { t.Errorf("edit target = %q, want %q", sender.editTargets[0], "$progress_msg_1") } } func TestProgressReporter_MultipleToolUse(t *testing.T) { sender := &mockProgressSender{} pr := NewProgressReporter(sender, "!room:test", slog.Default()) pr.minInterval = 0 // disable rate limiting for test fn := pr.StreamFunc() fn(coretypes.StreamEvent{Kind: coretypes.StreamInit}) fn(coretypes.StreamEvent{Kind: coretypes.StreamToolUse, ToolName: "Bash", ToolInput: "echo 1"}) fn(coretypes.StreamEvent{Kind: coretypes.StreamToolUse, ToolName: "Read", ToolInput: "/tmp/file.go"}) fn(coretypes.StreamEvent{Kind: coretypes.StreamToolUse, ToolName: "Edit", ToolInput: "/tmp/file.go"}) // 1 send (init) + 3 edits (tool uses) if len(sender.sends) != 1 { t.Errorf("expected 1 send, got %d", len(sender.sends)) } if len(sender.edits) != 3 { t.Errorf("expected 3 edits, got %d", len(sender.edits)) } } func TestProgressReporter_RateLimiting(t *testing.T) { sender := &mockProgressSender{} pr := NewProgressReporter(sender, "!room:test", slog.Default()) pr.minInterval = 500 * time.Millisecond fn := pr.StreamFunc() // First event creates the message (no rate limit on first send) fn(coretypes.StreamEvent{Kind: coretypes.StreamInit}) // Reset lastEdit to simulate time having passed after init pr.mu.Lock() pr.lastEdit = time.Now().Add(-time.Second) pr.mu.Unlock() // First tool event should go through (enough time has passed) fn(coretypes.StreamEvent{Kind: coretypes.StreamToolUse, ToolName: "Bash", ToolInput: "echo 1"}) // These rapid-fire events should be rate-limited fn(coretypes.StreamEvent{Kind: coretypes.StreamToolUse, ToolName: "Read", ToolInput: "file.go"}) fn(coretypes.StreamEvent{Kind: coretypes.StreamToolUse, ToolName: "Edit", ToolInput: "file.go"}) // Only 1 edit should have gone through (the rest rate limited) if len(sender.edits) != 1 { t.Errorf("expected 1 edit (rate limited), got %d", len(sender.edits)) } } func TestProgressReporter_ResultIgnored(t *testing.T) { sender := &mockProgressSender{} pr := NewProgressReporter(sender, "!room:test", slog.Default()) fn := pr.StreamFunc() fn(coretypes.StreamEvent{Kind: coretypes.StreamInit}) fn(coretypes.StreamEvent{Kind: coretypes.StreamResult, Content: "Final answer"}) // Result should not trigger an edit if len(sender.edits) != 0 { t.Errorf("expected 0 edits for result event, got %d", len(sender.edits)) } } func TestProgressReporter_TextIgnored(t *testing.T) { sender := &mockProgressSender{} pr := NewProgressReporter(sender, "!room:test", slog.Default()) fn := pr.StreamFunc() fn(coretypes.StreamEvent{Kind: coretypes.StreamInit}) fn(coretypes.StreamEvent{Kind: coretypes.StreamText, Content: "Some thinking..."}) // Text events should not trigger edits if len(sender.edits) != 0 { t.Errorf("expected 0 edits for text event, got %d", len(sender.edits)) } } func TestProgressReporter_Finalize(t *testing.T) { sender := &mockProgressSender{} pr := NewProgressReporter(sender, "!room:test", slog.Default()) pr.minInterval = 0 fn := pr.StreamFunc() fn(coretypes.StreamEvent{Kind: coretypes.StreamInit}) pr.Finalize("Done! Here is the result.") if len(sender.edits) != 1 { t.Fatalf("expected 1 edit for finalize, got %d", len(sender.edits)) } if sender.edits[0] != "Done! Here is the result." { t.Errorf("finalize edit = %q", sender.edits[0]) } } func TestProgressReporter_FinalizeNoMessage(t *testing.T) { sender := &mockProgressSender{} pr := NewProgressReporter(sender, "!room:test", slog.Default()) // Finalize without ever sending a message should be a no-op pr.Finalize("Final") if len(sender.edits) != 0 { t.Errorf("expected 0 edits when no message was sent, got %d", len(sender.edits)) } } func TestProgressReporter_FinalizeEmpty(t *testing.T) { sender := &mockProgressSender{} pr := NewProgressReporter(sender, "!room:test", slog.Default()) pr.minInterval = 0 fn := pr.StreamFunc() fn(coretypes.StreamEvent{Kind: coretypes.StreamInit}) // Empty finalize should be a no-op pr.Finalize("") if len(sender.edits) != 0 { t.Errorf("expected 0 edits for empty finalize, got %d", len(sender.edits)) } } func TestProgressReporter_ToolInputTruncation(t *testing.T) { sender := &mockProgressSender{} pr := NewProgressReporter(sender, "!room:test", slog.Default()) pr.minInterval = 0 fn := pr.StreamFunc() fn(coretypes.StreamEvent{Kind: coretypes.StreamInit}) longInput := strings.Repeat("x", 100) fn(coretypes.StreamEvent{Kind: coretypes.StreamToolUse, ToolName: "Bash", ToolInput: longInput}) if len(sender.edits) != 1 { t.Fatalf("expected 1 edit, got %d", len(sender.edits)) } // The input in the message should be truncated if strings.Contains(sender.edits[0], longInput) { t.Error("long input should be truncated in the message") } if !strings.Contains(sender.edits[0], "...") { t.Error("truncated input should end with ...") } }