Files
Duy /zuey/ f771cff77c fix(channels): coalesce multi-attachment inbounds (#63) (#90)
Implements 3 coalescing layers to handle rapid multi-attachment inbounds:
- Bus debouncer: delays inbound messages 1s, merges duplicates
- Web chat debouncer: buffers client-side inbound frames for batch RPC
- Telegram album aggregator: collects album members via AfterFunc+Stop timer

Drops media-bypass shortcut (forces 1s media floor). Aggregator enforces:
- AfterFunc+Stop timer discipline with ordered drain on stop
- 2-tuple (album_id, sender) keying for isolation
- Dual DoS caps: max 10 albums per sender, max 100 messages per album
- merged_message_ids dedup seeding across all 3 surfaces

Closes #63
2026-05-28 18:30:34 +07:00

114 lines
3.5 KiB
Go

package methods
import (
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
func TestMergeChatSendRequestsJoinsContentAndUsesLatestParams(t *testing.T) {
items := []chatSendRequest{
{params: chatSendParams{Message: "first", AgentID: "agent-a", SessionKey: "session-a", Stream: false}},
{params: chatSendParams{Message: "", AgentID: "agent-a", SessionKey: "session-a", Stream: true}},
{params: chatSendParams{Message: "second", AgentID: "agent-a", SessionKey: "session-a", Stream: true}},
}
got := mergeChatSendRequests(items)
if got.Message != "first\nsecond" {
t.Fatalf("merged message = %q, want %q", got.Message, "first\nsecond")
}
if !got.Stream {
t.Fatal("latest params should win for stream flag")
}
}
func TestChatDebouncerFlushesOnceAfterQuietWindow(t *testing.T) {
out := make(chan []chatSendRequest, 1)
d := newChatDebouncer(func(items []chatSendRequest) {
out <- items
})
defer d.Stop()
d.Push("u1:s1", 20*time.Millisecond, chatSendRequest{params: chatSendParams{Message: "one"}})
d.Push("u1:s1", 20*time.Millisecond, chatSendRequest{params: chatSendParams{Message: "two"}})
items := waitChatDebounce(t, out)
if len(items) != 2 {
t.Fatalf("flushed items = %d, want 2", len(items))
}
if got := mergeChatSendRequests(items).Message; got != "one\ntwo" {
t.Fatalf("merged message = %q", got)
}
}
func TestChatDebouncerTakeDrainsPendingBeforeBypass(t *testing.T) {
out := make(chan []chatSendRequest, 1)
d := newChatDebouncer(func(items []chatSendRequest) {
out <- items
})
defer d.Stop()
d.Push("u1:s1", time.Minute, chatSendRequest{params: chatSendParams{Message: "pending"}})
items := d.Take("u1:s1")
if len(items) != 1 || items[0].params.Message != "pending" {
t.Fatalf("flushed items = %#v", items)
}
assertNoChatDebounceFlush(t, out)
}
func TestChatDebouncerDiscardDropsPendingBeforeCancel(t *testing.T) {
out := make(chan []chatSendRequest, 1)
d := newChatDebouncer(func(items []chatSendRequest) {
out <- items
})
defer d.Stop()
d.Push("u1:s1", 20*time.Millisecond, chatSendRequest{params: chatSendParams{Message: "pending"}})
d.Discard("u1:s1")
assertNoChatDebounceFlush(t, out)
}
func TestChatDebounceDelayGlobalAndAgentOverride(t *testing.T) {
// hasMedia=false: legacy behavior preserved (no floor applied).
if got := chatDebounceDelay(&config.Config{}, nil, false); got != 0 {
t.Fatalf("default debounce = %s, want disabled", got)
}
cfg := &config.Config{}
cfg.Gateway.InboundDebounceMs = 250
if got := chatDebounceDelay(cfg, nil, false); got != 250*time.Millisecond {
t.Fatalf("global debounce = %s, want 250ms", got)
}
if got := chatDebounceDelay(cfg, []byte(`{"inbound_debounce_ms":0}`), false); got != 0 {
t.Fatalf("agent disabled debounce = %s, want disabled", got)
}
if got := chatDebounceDelay(cfg, []byte(`{"inbound_debounce_ms":500}`), false); got != 500*time.Millisecond {
t.Fatalf("agent custom debounce = %s, want 500ms", got)
}
if got := chatDebounceDelay(cfg, []byte(`{"other":true}`), false); got != 250*time.Millisecond {
t.Fatalf("agent inherit debounce = %s, want 250ms", got)
}
}
func waitChatDebounce(t *testing.T, ch <-chan []chatSendRequest) []chatSendRequest {
t.Helper()
select {
case items := <-ch:
return items
case <-time.After(500 * time.Millisecond):
t.Fatal("timed out waiting for chat debounce flush")
return nil
}
}
func assertNoChatDebounceFlush(t *testing.T, ch <-chan []chatSendRequest) {
t.Helper()
select {
case items := <-ch:
t.Fatalf("unexpected flush: %#v", items)
case <-time.After(50 * time.Millisecond):
}
}