Add a non-blocking runtime publish path and switch hot-path publishers to it. Enforce subscription timeout boundaries, keep ordered subscriber snapshots up to date on subscribe changes, expose all runtime kinds to process hooks, add safe log attrs for non-agent events, and close the gateway message bus on full shutdown.
2536 lines
67 KiB
Go
2536 lines
67 KiB
Go
package channels
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/time/rate"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
|
"github.com/sipeed/picoclaw/pkg/utils"
|
|
)
|
|
|
|
// mockChannel is a test double that delegates Send to a configurable function.
|
|
type mockChannel struct {
|
|
BaseChannel
|
|
sendFn func(ctx context.Context, msg bus.OutboundMessage) error
|
|
startFn func(ctx context.Context) error
|
|
stopFn func(ctx context.Context) error
|
|
sentMessages []bus.OutboundMessage
|
|
placeholdersSent int
|
|
editedMessages int
|
|
lastPlaceholderID string
|
|
}
|
|
|
|
func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
|
m.sentMessages = append(m.sentMessages, msg)
|
|
if m.sendFn == nil {
|
|
return nil, nil
|
|
}
|
|
return nil, m.sendFn(ctx, msg)
|
|
}
|
|
|
|
func (m *mockChannel) Start(ctx context.Context) error {
|
|
if m.startFn != nil {
|
|
return m.startFn(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockChannel) Stop(ctx context.Context) error {
|
|
if m.stopFn != nil {
|
|
return m.stopFn(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
|
m.placeholdersSent++
|
|
m.lastPlaceholderID = "mock-ph-123"
|
|
return m.lastPlaceholderID, nil
|
|
}
|
|
|
|
func (m *mockChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error {
|
|
m.editedMessages++
|
|
return nil
|
|
}
|
|
|
|
type mockMediaChannel struct {
|
|
mockChannel
|
|
sendMediaFn func(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error)
|
|
sentMediaMessages []bus.OutboundMediaMessage
|
|
}
|
|
|
|
func (m *mockMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
|
|
m.sentMediaMessages = append(m.sentMediaMessages, msg)
|
|
if m.sendMediaFn != nil {
|
|
return m.sendMediaFn(ctx, msg)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
type mockDeletingMediaChannel struct {
|
|
mockMediaChannel
|
|
deleteCalls int
|
|
dismissedChatID string
|
|
lastDeleted struct {
|
|
chatID string
|
|
messageID string
|
|
}
|
|
}
|
|
|
|
func (m *mockDeletingMediaChannel) DeleteMessage(
|
|
_ context.Context,
|
|
chatID string,
|
|
messageID string,
|
|
) error {
|
|
m.deleteCalls++
|
|
m.lastDeleted.chatID = chatID
|
|
m.lastDeleted.messageID = messageID
|
|
return nil
|
|
}
|
|
|
|
func (m *mockDeletingMediaChannel) DismissToolFeedbackMessage(_ context.Context, chatID string) {
|
|
m.dismissedChatID = chatID
|
|
}
|
|
|
|
type mockStreamer struct {
|
|
finalizeFn func(context.Context, string) error
|
|
}
|
|
|
|
func (m *mockStreamer) Update(context.Context, string) error { return nil }
|
|
|
|
func (m *mockStreamer) Finalize(ctx context.Context, content string) error {
|
|
if m.finalizeFn != nil {
|
|
return m.finalizeFn(ctx, content)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockStreamer) Cancel(context.Context) {}
|
|
|
|
type mockStreamingChannel struct {
|
|
mockMessageEditor
|
|
streamer Streamer
|
|
resolveChatIDFn func(chatID string, outboundCtx *bus.InboundContext) string
|
|
}
|
|
|
|
func (m *mockStreamingChannel) BeginStream(context.Context, string) (Streamer, error) {
|
|
if m.streamer == nil {
|
|
return nil, errors.New("missing streamer")
|
|
}
|
|
return m.streamer, nil
|
|
}
|
|
|
|
func (m *mockStreamingChannel) ToolFeedbackMessageChatID(
|
|
chatID string,
|
|
outboundCtx *bus.InboundContext,
|
|
) string {
|
|
if m.resolveChatIDFn != nil {
|
|
return m.resolveChatIDFn(chatID, outboundCtx)
|
|
}
|
|
return chatID
|
|
}
|
|
|
|
// newTestManager creates a minimal Manager suitable for unit tests.
|
|
func newTestManager() *Manager {
|
|
return &Manager{
|
|
channels: make(map[string]Channel),
|
|
workers: make(map[string]*channelWorker),
|
|
bus: bus.NewMessageBus(),
|
|
}
|
|
}
|
|
|
|
func TestStartAll_AllChannelsFail_ReturnsJoinedError(t *testing.T) {
|
|
m := newTestManager()
|
|
errA := errors.New("channel-a start failed")
|
|
errB := errors.New("channel-b start failed")
|
|
|
|
m.channels["a"] = &mockChannel{
|
|
startFn: func(_ context.Context) error { return errA },
|
|
}
|
|
m.channels["b"] = &mockChannel{
|
|
startFn: func(_ context.Context) error { return errB },
|
|
}
|
|
|
|
err := m.StartAll(t.Context())
|
|
if err == nil {
|
|
t.Fatal("expected StartAll to fail when all channels fail")
|
|
}
|
|
if !strings.Contains(err.Error(), "failed to start any enabled channels") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !errors.Is(err, errA) {
|
|
t.Fatalf("expected error to wrap errA, got: %v", err)
|
|
}
|
|
if !errors.Is(err, errB) {
|
|
t.Fatalf("expected error to wrap errB, got: %v", err)
|
|
}
|
|
if len(m.workers) != 0 {
|
|
t.Fatalf("expected no workers on full startup failure, got %d", len(m.workers))
|
|
}
|
|
if m.dispatchTask != nil {
|
|
t.Fatal("expected dispatch task to be cleared on full startup failure")
|
|
}
|
|
}
|
|
|
|
func TestStartAll_PartialFailure_StartsSuccessfulWorkers(t *testing.T) {
|
|
m := newTestManager()
|
|
errBad := errors.New("bad channel start failed")
|
|
processed := make(chan struct{}, 1)
|
|
|
|
m.channels["good"] = &mockChannel{
|
|
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
|
|
if msg.Channel == "good" {
|
|
select {
|
|
case processed <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
m.channels["bad"] = &mockChannel{
|
|
startFn: func(_ context.Context) error { return errBad },
|
|
}
|
|
|
|
err := m.StartAll(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("expected StartAll to succeed with partial channel failures, got: %v", err)
|
|
}
|
|
if len(m.workers) != 1 {
|
|
t.Fatalf("expected exactly 1 active worker, got %d", len(m.workers))
|
|
}
|
|
if _, ok := m.workers["good"]; !ok {
|
|
t.Fatal("expected worker for successful channel 'good'")
|
|
}
|
|
if _, ok := m.workers["bad"]; ok {
|
|
t.Fatal("did not expect worker for failed channel 'bad'")
|
|
}
|
|
if m.dispatchTask == nil {
|
|
t.Fatal("expected dispatch task to run when at least one channel starts")
|
|
}
|
|
|
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer pubCancel()
|
|
if err := m.bus.PublishOutbound(pubCtx, testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "good",
|
|
ChatID: "chat-1",
|
|
Content: "hello",
|
|
})); err != nil {
|
|
t.Fatalf("PublishOutbound() error = %v", err)
|
|
}
|
|
|
|
select {
|
|
case <-processed:
|
|
// worker processed outbound message as expected
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("expected successful channel worker to process outbound message")
|
|
}
|
|
|
|
stopCtx, stopCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer stopCancel()
|
|
if err := m.StopAll(stopCtx); err != nil {
|
|
t.Fatalf("StopAll() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStartAllPublishesLifecycleRuntimeEvents(t *testing.T) {
|
|
eventBus := runtimeevents.NewBus()
|
|
defer func() {
|
|
if err := eventBus.Close(); err != nil {
|
|
t.Errorf("event bus close failed: %v", err)
|
|
}
|
|
}()
|
|
|
|
_, eventsCh, err := eventBus.Channel().SubscribeChan(
|
|
t.Context(),
|
|
runtimeevents.SubscribeOptions{Name: "channel-lifecycle", Buffer: 4},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("SubscribeChan failed: %v", err)
|
|
}
|
|
|
|
m := newTestManager()
|
|
m.runtimeEvents = eventBus
|
|
m.config = &config.Config{Channels: config.ChannelsConfig{}}
|
|
m.channels["good"] = &mockChannel{}
|
|
m.channels["bad"] = &mockChannel{
|
|
startFn: func(_ context.Context) error { return errors.New("bad start") },
|
|
}
|
|
|
|
if err := m.StartAll(t.Context()); err != nil {
|
|
t.Fatalf("StartAll() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := m.StopAll(stopCtx); err != nil {
|
|
t.Errorf("StopAll() error = %v", err)
|
|
}
|
|
})
|
|
|
|
events := []runtimeevents.Event{
|
|
receiveChannelRuntimeEvent(t, eventsCh),
|
|
receiveChannelRuntimeEvent(t, eventsCh),
|
|
}
|
|
seen := map[runtimeevents.Kind]runtimeevents.Event{}
|
|
for _, evt := range events {
|
|
seen[evt.Kind] = evt
|
|
}
|
|
if evt, ok := seen[runtimeevents.KindChannelLifecycleStarted]; !ok || evt.Scope.Channel != "good" {
|
|
t.Fatalf("missing started event for good channel: %+v", events)
|
|
}
|
|
if evt, ok := seen[runtimeevents.KindChannelLifecycleStartFailed]; !ok || evt.Scope.Channel != "bad" {
|
|
t.Fatalf("missing failed event for bad channel: %+v", events)
|
|
}
|
|
}
|
|
|
|
func testOutboundMessage(msg bus.OutboundMessage) bus.OutboundMessage {
|
|
if msg.Context.Channel == "" && msg.Context.ChatID == "" {
|
|
msg.Context = bus.NewOutboundContext(msg.Channel, msg.ChatID, msg.ReplyToMessageID)
|
|
}
|
|
return bus.NormalizeOutboundMessage(msg)
|
|
}
|
|
|
|
func testOutboundMediaMessage(msg bus.OutboundMediaMessage) bus.OutboundMediaMessage {
|
|
if msg.Context.Channel == "" && msg.Context.ChatID == "" {
|
|
msg.Context = bus.NewOutboundContext(msg.Channel, msg.ChatID, "")
|
|
}
|
|
return bus.NormalizeOutboundMediaMessage(msg)
|
|
}
|
|
|
|
func receiveChannelRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event {
|
|
t.Helper()
|
|
|
|
select {
|
|
case evt, ok := <-ch:
|
|
if !ok {
|
|
t.Fatal("runtime event channel closed before expected event")
|
|
}
|
|
return evt
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for runtime event")
|
|
return runtimeevents.Event{}
|
|
}
|
|
}
|
|
|
|
func TestSendWithRetry_Success(t *testing.T) {
|
|
m := newTestManager()
|
|
var callCount int
|
|
ch := &mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
callCount++
|
|
return nil
|
|
},
|
|
}
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
|
|
ctx := context.Background()
|
|
msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"})
|
|
|
|
m.sendWithRetry(ctx, "test", w, msg)
|
|
|
|
if callCount != 1 {
|
|
t.Fatalf("expected 1 Send call, got %d", callCount)
|
|
}
|
|
}
|
|
|
|
func TestSendWithRetryPublishesOutboundRuntimeEvents(t *testing.T) {
|
|
eventBus := runtimeevents.NewBus()
|
|
defer func() {
|
|
if err := eventBus.Close(); err != nil {
|
|
t.Errorf("event bus close failed: %v", err)
|
|
}
|
|
}()
|
|
|
|
_, eventsCh, err := eventBus.Channel().OfKind(
|
|
runtimeevents.KindChannelMessageOutboundSent,
|
|
runtimeevents.KindChannelMessageOutboundFailed,
|
|
).SubscribeChan(t.Context(), runtimeevents.SubscribeOptions{Name: "channel-outbound", Buffer: 2})
|
|
if err != nil {
|
|
t.Fatalf("SubscribeChan failed: %v", err)
|
|
}
|
|
|
|
m := newTestManager()
|
|
m.runtimeEvents = eventBus
|
|
|
|
successWorker := &channelWorker{
|
|
ch: &mockChannel{},
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
m.sendWithRetry(
|
|
context.Background(),
|
|
"test",
|
|
successWorker,
|
|
testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "chat-1", Content: "hello"}),
|
|
)
|
|
sent := receiveChannelRuntimeEvent(t, eventsCh)
|
|
if sent.Kind != runtimeevents.KindChannelMessageOutboundSent || sent.Scope.ChatID != "chat-1" {
|
|
t.Fatalf("sent event = %+v", sent)
|
|
}
|
|
if sent.Attrs["content_len"] != 5 {
|
|
t.Fatalf("sent attrs = %#v, want content_len", sent.Attrs)
|
|
}
|
|
|
|
failWorker := &channelWorker{
|
|
ch: &mockChannel{
|
|
sendFn: func(context.Context, bus.OutboundMessage) error {
|
|
return fmt.Errorf("send failed: %w", ErrSendFailed)
|
|
},
|
|
},
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
m.sendWithRetry(
|
|
context.Background(),
|
|
"test",
|
|
failWorker,
|
|
testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "chat-2", Content: "hello"}),
|
|
)
|
|
failed := receiveChannelRuntimeEvent(t, eventsCh)
|
|
if failed.Kind != runtimeevents.KindChannelMessageOutboundFailed || failed.Scope.ChatID != "chat-2" {
|
|
t.Fatalf("failed event = %+v", failed)
|
|
}
|
|
if failed.Severity != runtimeevents.SeverityError {
|
|
t.Fatalf("failed severity = %q", failed.Severity)
|
|
}
|
|
if failed.Attrs["error"] == "" || failed.Attrs["retries"] != maxRetries {
|
|
t.Fatalf("failed attrs = %#v, want error and retries", failed.Attrs)
|
|
}
|
|
}
|
|
|
|
func TestSendWithRetry_TemporaryThenSuccess(t *testing.T) {
|
|
m := newTestManager()
|
|
var callCount int
|
|
ch := &mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
callCount++
|
|
if callCount <= 2 {
|
|
return fmt.Errorf("network error: %w", ErrTemporary)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
|
|
ctx := context.Background()
|
|
msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"})
|
|
|
|
m.sendWithRetry(ctx, "test", w, msg)
|
|
|
|
if callCount != 3 {
|
|
t.Fatalf("expected 3 Send calls (2 failures + 1 success), got %d", callCount)
|
|
}
|
|
}
|
|
|
|
func TestSendWithRetry_PermanentFailure(t *testing.T) {
|
|
m := newTestManager()
|
|
var callCount int
|
|
ch := &mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
callCount++
|
|
return fmt.Errorf("bad chat ID: %w", ErrSendFailed)
|
|
},
|
|
}
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
|
|
ctx := context.Background()
|
|
msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"})
|
|
|
|
m.sendWithRetry(ctx, "test", w, msg)
|
|
|
|
if callCount != 1 {
|
|
t.Fatalf("expected 1 Send call (no retry for permanent failure), got %d", callCount)
|
|
}
|
|
}
|
|
|
|
func TestSendWithRetry_NotRunning(t *testing.T) {
|
|
m := newTestManager()
|
|
var callCount int
|
|
ch := &mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
callCount++
|
|
return ErrNotRunning
|
|
},
|
|
}
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
|
|
ctx := context.Background()
|
|
msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"})
|
|
|
|
m.sendWithRetry(ctx, "test", w, msg)
|
|
|
|
if callCount != 1 {
|
|
t.Fatalf("expected 1 Send call (no retry for ErrNotRunning), got %d", callCount)
|
|
}
|
|
}
|
|
|
|
func TestSendWithRetry_RateLimitRetry(t *testing.T) {
|
|
m := newTestManager()
|
|
var callCount int
|
|
ch := &mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
callCount++
|
|
if callCount == 1 {
|
|
return fmt.Errorf("429: %w", ErrRateLimit)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
|
|
ctx := context.Background()
|
|
msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"})
|
|
|
|
start := time.Now()
|
|
m.sendWithRetry(ctx, "test", w, msg)
|
|
elapsed := time.Since(start)
|
|
|
|
if callCount != 2 {
|
|
t.Fatalf("expected 2 Send calls (1 rate limit + 1 success), got %d", callCount)
|
|
}
|
|
// Should have waited at least rateLimitDelay (1s) but allow some slack
|
|
if elapsed < 900*time.Millisecond {
|
|
t.Fatalf("expected at least ~1s delay for rate limit retry, got %v", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestSendWithRetry_MaxRetriesExhausted(t *testing.T) {
|
|
m := newTestManager()
|
|
var callCount int
|
|
ch := &mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
callCount++
|
|
return fmt.Errorf("timeout: %w", ErrTemporary)
|
|
},
|
|
}
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
|
|
ctx := context.Background()
|
|
msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"})
|
|
|
|
m.sendWithRetry(ctx, "test", w, msg)
|
|
|
|
expected := maxRetries + 1 // initial attempt + maxRetries retries
|
|
if callCount != expected {
|
|
t.Fatalf("expected %d Send calls, got %d", expected, callCount)
|
|
}
|
|
}
|
|
|
|
func TestSendMedia_Success(t *testing.T) {
|
|
m := newTestManager()
|
|
var callCount int
|
|
ch := &mockMediaChannel{
|
|
sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) ([]string, error) {
|
|
callCount++
|
|
return nil, nil
|
|
},
|
|
}
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
m.channels["test"] = ch
|
|
m.workers["test"] = w
|
|
|
|
err := m.SendMedia(context.Background(), testOutboundMediaMessage(bus.OutboundMediaMessage{
|
|
Channel: "test",
|
|
ChatID: "chat1",
|
|
Parts: []bus.MediaPart{{Ref: "media://abc"}},
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("SendMedia() error = %v", err)
|
|
}
|
|
if callCount != 1 {
|
|
t.Fatalf("expected 1 SendMedia call, got %d", callCount)
|
|
}
|
|
}
|
|
|
|
func TestSendMedia_PropagatesFailure(t *testing.T) {
|
|
m := newTestManager()
|
|
ch := &mockMediaChannel{
|
|
sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) ([]string, error) {
|
|
return nil, fmt.Errorf("bad upload: %w", ErrSendFailed)
|
|
},
|
|
}
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
m.channels["test"] = ch
|
|
m.workers["test"] = w
|
|
|
|
err := m.SendMedia(context.Background(), testOutboundMediaMessage(bus.OutboundMediaMessage{
|
|
Channel: "test",
|
|
ChatID: "chat1",
|
|
Parts: []bus.MediaPart{{Ref: "media://abc"}},
|
|
}))
|
|
if err == nil {
|
|
t.Fatal("expected SendMedia to return error")
|
|
}
|
|
if !errors.Is(err, ErrSendFailed) {
|
|
t.Fatalf("expected ErrSendFailed, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSendMedia_UnsupportedChannelReturnsError(t *testing.T) {
|
|
m := newTestManager()
|
|
ch := &mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
return nil
|
|
},
|
|
}
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
m.channels["test"] = ch
|
|
m.workers["test"] = w
|
|
|
|
err := m.SendMedia(context.Background(), testOutboundMediaMessage(bus.OutboundMediaMessage{
|
|
Channel: "test",
|
|
ChatID: "chat1",
|
|
Parts: []bus.MediaPart{{Ref: "media://abc"}},
|
|
}))
|
|
if err == nil {
|
|
t.Fatal("expected SendMedia to return error for unsupported channel")
|
|
}
|
|
if !strings.Contains(err.Error(), "does not support media sending") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSendMedia_DeletesPlaceholderBeforeSending(t *testing.T) {
|
|
m := newTestManager()
|
|
ch := &mockDeletingMediaChannel{
|
|
mockMediaChannel: mockMediaChannel{
|
|
sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) ([]string, error) {
|
|
return nil, nil
|
|
},
|
|
},
|
|
}
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
m.channels["test"] = ch
|
|
m.workers["test"] = w
|
|
m.RecordPlaceholder("test", "chat1", "placeholder-1")
|
|
|
|
err := m.SendMedia(context.Background(), testOutboundMediaMessage(bus.OutboundMediaMessage{
|
|
Channel: "test",
|
|
ChatID: "chat1",
|
|
Parts: []bus.MediaPart{{Ref: "media://abc"}},
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("SendMedia() error = %v", err)
|
|
}
|
|
if ch.deleteCalls != 1 {
|
|
t.Fatalf("expected placeholder delete to be called once, got %d", ch.deleteCalls)
|
|
}
|
|
if ch.lastDeleted.chatID != "chat1" || ch.lastDeleted.messageID != "placeholder-1" {
|
|
t.Fatalf("unexpected placeholder deletion target: %+v", ch.lastDeleted)
|
|
}
|
|
if len(ch.sentMediaMessages) != 1 {
|
|
t.Fatalf("expected media to be sent once, got %d", len(ch.sentMediaMessages))
|
|
}
|
|
}
|
|
|
|
func TestSendWithRetry_UnknownError(t *testing.T) {
|
|
m := newTestManager()
|
|
var callCount int
|
|
ch := &mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
callCount++
|
|
if callCount == 1 {
|
|
return errors.New("random unexpected error")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
|
|
ctx := context.Background()
|
|
msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"})
|
|
|
|
m.sendWithRetry(ctx, "test", w, msg)
|
|
|
|
if callCount != 2 {
|
|
t.Fatalf("expected 2 Send calls (unknown error treated as temporary), got %d", callCount)
|
|
}
|
|
}
|
|
|
|
func TestSendWithRetry_ContextCancelled(t *testing.T) {
|
|
m := newTestManager()
|
|
var callCount int
|
|
ch := &mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
callCount++
|
|
return fmt.Errorf("timeout: %w", ErrTemporary)
|
|
},
|
|
}
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"})
|
|
|
|
// Cancel context after first Send attempt returns
|
|
ch.sendFn = func(_ context.Context, _ bus.OutboundMessage) error {
|
|
callCount++
|
|
cancel()
|
|
return fmt.Errorf("timeout: %w", ErrTemporary)
|
|
}
|
|
|
|
m.sendWithRetry(ctx, "test", w, msg)
|
|
|
|
// Should have called Send once, then noticed ctx canceled during backoff
|
|
if callCount != 1 {
|
|
t.Fatalf("expected 1 Send call before context cancellation, got %d", callCount)
|
|
}
|
|
}
|
|
|
|
func TestWorkerRateLimiter(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
var mu sync.Mutex
|
|
var sendTimes []time.Time
|
|
|
|
ch := &mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
mu.Lock()
|
|
sendTimes = append(sendTimes, time.Now())
|
|
mu.Unlock()
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// Create a worker with a low rate: 2 msg/s, burst 1
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
queue: make(chan bus.OutboundMessage, 10),
|
|
done: make(chan struct{}),
|
|
limiter: rate.NewLimiter(2, 1),
|
|
}
|
|
|
|
ctx := t.Context()
|
|
|
|
go m.runWorker(ctx, "test", w)
|
|
|
|
// Enqueue 4 messages
|
|
for i := range 4 {
|
|
w.queue <- testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: fmt.Sprintf("msg%d", i)})
|
|
}
|
|
|
|
// Wait enough time for all messages to be sent (4 msgs at 2/s = ~2s, give extra margin)
|
|
time.Sleep(3 * time.Second)
|
|
|
|
mu.Lock()
|
|
times := make([]time.Time, len(sendTimes))
|
|
copy(times, sendTimes)
|
|
mu.Unlock()
|
|
|
|
if len(times) != 4 {
|
|
t.Fatalf("expected 4 sends, got %d", len(times))
|
|
}
|
|
|
|
// Verify rate limiting: total duration should be at least 1s
|
|
// (first message immediate, then ~500ms between each subsequent one at 2/s)
|
|
totalDuration := times[len(times)-1].Sub(times[0])
|
|
if totalDuration < 1*time.Second {
|
|
t.Fatalf("expected total duration >= 1s for 4 msgs at 2/s rate, got %v", totalDuration)
|
|
}
|
|
}
|
|
|
|
func TestNewChannelWorker_DefaultRate(t *testing.T) {
|
|
ch := &mockChannel{}
|
|
w := newChannelWorker("unknown_channel", ch, "unknown_channel")
|
|
|
|
if w.limiter == nil {
|
|
t.Fatal("expected limiter to be non-nil")
|
|
}
|
|
if w.limiter.Limit() != rate.Limit(defaultRateLimit) {
|
|
t.Fatalf("expected rate limit %v, got %v", rate.Limit(defaultRateLimit), w.limiter.Limit())
|
|
}
|
|
}
|
|
|
|
func TestNewChannelWorker_ConfiguredRate(t *testing.T) {
|
|
ch := &mockChannel{}
|
|
|
|
for channelType, expectedRate := range channelRateConfig {
|
|
w := newChannelWorker(channelType, ch, channelType)
|
|
if w.limiter.Limit() != rate.Limit(expectedRate) {
|
|
t.Fatalf("channel %s: expected rate %v, got %v", channelType, expectedRate, w.limiter.Limit())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunWorker_MessageSplitting(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
var mu sync.Mutex
|
|
var received []string
|
|
|
|
ch := &mockChannelWithLength{
|
|
mockChannel: mockChannel{
|
|
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
|
|
mu.Lock()
|
|
received = append(received, msg.Content)
|
|
mu.Unlock()
|
|
return nil
|
|
},
|
|
},
|
|
maxLen: 5,
|
|
}
|
|
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
queue: make(chan bus.OutboundMessage, 10),
|
|
done: make(chan struct{}),
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
|
|
ctx := t.Context()
|
|
|
|
go m.runWorker(ctx, "test", w)
|
|
|
|
// Send a message that should be split
|
|
w.queue <- testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello world"})
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
mu.Lock()
|
|
count := len(received)
|
|
mu.Unlock()
|
|
|
|
if count < 2 {
|
|
t.Fatalf("expected message to be split into at least 2 chunks, got %d", count)
|
|
}
|
|
}
|
|
|
|
// mockChannelWithLength implements MessageLengthProvider.
|
|
type mockChannelWithLength struct {
|
|
mockChannel
|
|
maxLen int
|
|
}
|
|
|
|
func (m *mockChannelWithLength) MaxMessageLength() int {
|
|
return m.maxLen
|
|
}
|
|
|
|
func TestSendWithRetry_ExponentialBackoff(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
var callTimes []time.Time
|
|
var callCount atomic.Int32
|
|
ch := &mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
callTimes = append(callTimes, time.Now())
|
|
callCount.Add(1)
|
|
return fmt.Errorf("timeout: %w", ErrTemporary)
|
|
},
|
|
}
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
|
|
ctx := context.Background()
|
|
msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"})
|
|
|
|
start := time.Now()
|
|
m.sendWithRetry(ctx, "test", w, msg)
|
|
totalElapsed := time.Since(start)
|
|
|
|
// With maxRetries=3: attempts at 0, ~500ms, ~1.5s, ~3.5s
|
|
// Total backoff: 500ms + 1s + 2s = 3.5s
|
|
// Allow some margin
|
|
if totalElapsed < 3*time.Second {
|
|
t.Fatalf("expected total elapsed >= 3s for exponential backoff, got %v", totalElapsed)
|
|
}
|
|
|
|
if int(callCount.Load()) != maxRetries+1 {
|
|
t.Fatalf("expected %d calls, got %d", maxRetries+1, callCount.Load())
|
|
}
|
|
}
|
|
|
|
// --- Phase 10: preSend orchestration tests ---
|
|
|
|
// mockMessageEditor is a channel that supports MessageEditor.
|
|
type mockMessageEditor struct {
|
|
mockChannel
|
|
editFn func(ctx context.Context, chatID, messageID, content string) error
|
|
finalizeFn func(ctx context.Context, msg bus.OutboundMessage) ([]string, bool)
|
|
finalizeCalled bool
|
|
recordedChatID string
|
|
recordedMessageID string
|
|
recordedContent string
|
|
clearedChatID string
|
|
dismissedChatID string
|
|
}
|
|
|
|
func (m *mockMessageEditor) EditMessage(ctx context.Context, chatID, messageID, content string) error {
|
|
return m.editFn(ctx, chatID, messageID, content)
|
|
}
|
|
|
|
func (m *mockMessageEditor) RecordToolFeedbackMessage(chatID, messageID, content string) {
|
|
m.recordedChatID = chatID
|
|
m.recordedMessageID = messageID
|
|
m.recordedContent = content
|
|
}
|
|
|
|
func (m *mockMessageEditor) ClearToolFeedbackMessage(chatID string) {
|
|
m.clearedChatID = chatID
|
|
}
|
|
|
|
func (m *mockMessageEditor) DismissToolFeedbackMessage(_ context.Context, chatID string) {
|
|
m.dismissedChatID = chatID
|
|
}
|
|
|
|
func (m *mockMessageEditor) FinalizeToolFeedbackMessage(
|
|
ctx context.Context,
|
|
msg bus.OutboundMessage,
|
|
) ([]string, bool) {
|
|
m.finalizeCalled = true
|
|
if m.finalizeFn == nil {
|
|
return nil, false
|
|
}
|
|
return m.finalizeFn(ctx, msg)
|
|
}
|
|
|
|
type mockResolvedToolFeedbackEditor struct {
|
|
mockMessageEditor
|
|
resolveChatIDFn func(chatID string, outboundCtx *bus.InboundContext) string
|
|
}
|
|
|
|
type mockDeletingMessageEditor struct {
|
|
mockMessageEditor
|
|
deleteCalls int
|
|
deletedChatID string
|
|
deletedMessageID string
|
|
}
|
|
|
|
func (m *mockDeletingMessageEditor) DeleteMessage(_ context.Context, chatID, messageID string) error {
|
|
m.deleteCalls++
|
|
m.deletedChatID = chatID
|
|
m.deletedMessageID = messageID
|
|
return nil
|
|
}
|
|
|
|
func (m *mockResolvedToolFeedbackEditor) ToolFeedbackMessageChatID(
|
|
chatID string,
|
|
outboundCtx *bus.InboundContext,
|
|
) string {
|
|
if m.resolveChatIDFn != nil {
|
|
return m.resolveChatIDFn(chatID, outboundCtx)
|
|
}
|
|
return chatID
|
|
}
|
|
|
|
type mockPreparedToolFeedbackEditor struct {
|
|
mockMessageEditor
|
|
prepareFn func(content string) string
|
|
}
|
|
|
|
func (m *mockPreparedToolFeedbackEditor) PrepareToolFeedbackMessageContent(content string) string {
|
|
if m.prepareFn != nil {
|
|
return m.prepareFn(content)
|
|
}
|
|
return content
|
|
}
|
|
|
|
func TestPreSend_PlaceholderEditSuccess(t *testing.T) {
|
|
m := newTestManager()
|
|
var sendCalled bool
|
|
var editCalled bool
|
|
|
|
ch := &mockMessageEditor{
|
|
mockChannel: mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
sendCalled = true
|
|
return nil
|
|
},
|
|
},
|
|
editFn: func(_ context.Context, chatID, messageID, content string) error {
|
|
editCalled = true
|
|
if chatID != "123" {
|
|
t.Fatalf("expected chatID 123, got %s", chatID)
|
|
}
|
|
if messageID != "456" {
|
|
t.Fatalf("expected messageID 456, got %s", messageID)
|
|
}
|
|
if content != "hello" {
|
|
t.Fatalf("expected content 'hello', got %s", content)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// Register placeholder
|
|
m.RecordPlaceholder("test", "123", "456")
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"})
|
|
_, edited := m.preSend(context.Background(), "test", msg, ch)
|
|
|
|
if !edited {
|
|
t.Fatal("expected preSend to return true (placeholder edited)")
|
|
}
|
|
if !editCalled {
|
|
t.Fatal("expected EditMessage to be called")
|
|
}
|
|
if sendCalled {
|
|
t.Fatal("expected Send to NOT be called when placeholder edited")
|
|
}
|
|
}
|
|
|
|
func TestPreSend_ToolFeedbackPlaceholderEditRecordsTrackedMessage(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
ch := &mockMessageEditor{
|
|
editFn: func(_ context.Context, chatID, messageID, content string) error {
|
|
if chatID != "123" || messageID != "456" || content != "hello" {
|
|
t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
m.RecordPlaceholder("test", "123", "456")
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Content: "hello",
|
|
Context: bus.InboundContext{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Raw: map[string]string{
|
|
"message_kind": "tool_feedback",
|
|
},
|
|
},
|
|
})
|
|
_, edited := m.preSend(context.Background(), "test", msg, ch)
|
|
if !edited {
|
|
t.Fatal("expected preSend to edit placeholder")
|
|
}
|
|
if ch.recordedChatID != "123" || ch.recordedMessageID != "456" {
|
|
t.Fatalf("expected tracked message 123/456, got %q/%q", ch.recordedChatID, ch.recordedMessageID)
|
|
}
|
|
}
|
|
|
|
func TestPreSend_ToolFeedbackPlaceholderEditUsesResolvedTrackedChatID(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
ch := &mockResolvedToolFeedbackEditor{
|
|
mockMessageEditor: mockMessageEditor{
|
|
editFn: func(_ context.Context, chatID, messageID, content string) error {
|
|
if chatID != "-100123" || messageID != "456" || content != "hello" {
|
|
t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content)
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
resolveChatIDFn: func(chatID string, outboundCtx *bus.InboundContext) string {
|
|
if chatID != "-100123" {
|
|
t.Fatalf("expected raw chat ID, got %q", chatID)
|
|
}
|
|
if outboundCtx == nil || outboundCtx.TopicID != "42" {
|
|
t.Fatalf("expected topic-aware outbound context, got %+v", outboundCtx)
|
|
}
|
|
return chatID + "/" + outboundCtx.TopicID
|
|
},
|
|
}
|
|
|
|
m.RecordPlaceholder("test", "-100123", "456")
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "test",
|
|
ChatID: "-100123",
|
|
Content: "hello",
|
|
Context: bus.InboundContext{
|
|
Channel: "test",
|
|
ChatID: "-100123",
|
|
TopicID: "42",
|
|
Raw: map[string]string{
|
|
"message_kind": "tool_feedback",
|
|
},
|
|
},
|
|
})
|
|
_, edited := m.preSend(context.Background(), "test", msg, ch)
|
|
if !edited {
|
|
t.Fatal("expected preSend to edit placeholder")
|
|
}
|
|
if ch.recordedChatID != "-100123/42" || ch.recordedMessageID != "456" {
|
|
t.Fatalf("expected resolved tracked message -100123/42/456, got %q/%q",
|
|
ch.recordedChatID, ch.recordedMessageID)
|
|
}
|
|
}
|
|
|
|
func TestPreSend_ToolFeedbackPlaceholderEditUsesPreparedContent(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
const rawContent = "🔧 `read_file`\n" + "<raw>"
|
|
const preparedContent = "🔧 `read_file`\n<raw>"
|
|
|
|
ch := &mockPreparedToolFeedbackEditor{
|
|
mockMessageEditor: mockMessageEditor{
|
|
editFn: func(_ context.Context, chatID, messageID, content string) error {
|
|
if chatID != "123" || messageID != "456" {
|
|
t.Fatalf("unexpected edit target: %s/%s", chatID, messageID)
|
|
}
|
|
if content != InitialAnimatedToolFeedbackContent(preparedContent) {
|
|
t.Fatalf("unexpected prepared content: %q", content)
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
prepareFn: func(content string) string {
|
|
if content != rawContent {
|
|
t.Fatalf("unexpected raw tool feedback: %q", content)
|
|
}
|
|
return preparedContent
|
|
},
|
|
}
|
|
|
|
m.RecordPlaceholder("test", "123", "456")
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Content: rawContent,
|
|
Context: bus.InboundContext{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Raw: map[string]string{
|
|
"message_kind": "tool_feedback",
|
|
},
|
|
},
|
|
})
|
|
|
|
_, edited := m.preSend(context.Background(), "test", msg, ch)
|
|
if !edited {
|
|
t.Fatal("expected preSend to edit placeholder")
|
|
}
|
|
if ch.recordedContent != preparedContent {
|
|
t.Fatalf("expected tracked content %q, got %q", preparedContent, ch.recordedContent)
|
|
}
|
|
}
|
|
|
|
func TestPreSend_NonToolFeedbackLeavesTrackedMessageForChannelSend(t *testing.T) {
|
|
m := newTestManager()
|
|
ch := &mockMessageEditor{}
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Content: "final reply",
|
|
Context: bus.InboundContext{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
},
|
|
})
|
|
|
|
_, edited := m.preSend(context.Background(), "test", msg, ch)
|
|
if edited {
|
|
t.Fatal("expected preSend to fall through when no placeholder exists")
|
|
}
|
|
if ch.dismissedChatID != "" {
|
|
t.Fatalf("expected tracked tool feedback cleanup to be deferred to channel send, got %q", ch.dismissedChatID)
|
|
}
|
|
}
|
|
|
|
func TestPreSend_NonToolFeedbackDefersTrackedMessageFinalizationToChannelSend(t *testing.T) {
|
|
m := newTestManager()
|
|
ch := &mockMessageEditor{
|
|
finalizeFn: func(_ context.Context, msg bus.OutboundMessage) ([]string, bool) {
|
|
if msg.ChatID != "123" || msg.Content != "final reply" {
|
|
t.Fatalf("unexpected finalize msg: %+v", msg)
|
|
}
|
|
return []string{"tool-msg-1"}, true
|
|
},
|
|
}
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Content: "final reply",
|
|
Context: bus.InboundContext{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
},
|
|
})
|
|
|
|
msgIDs, handled := m.preSend(context.Background(), "test", msg, ch)
|
|
if handled {
|
|
t.Fatalf("expected preSend to defer to channel Send, got msgIDs=%v", msgIDs)
|
|
}
|
|
if len(msgIDs) != 0 {
|
|
t.Fatalf("expected no msgIDs from preSend, got %v", msgIDs)
|
|
}
|
|
if ch.dismissedChatID != "" {
|
|
t.Fatalf("expected tracked cleanup to remain in channel Send, got %q", ch.dismissedChatID)
|
|
}
|
|
if ch.finalizeCalled {
|
|
t.Fatal("expected preSend to skip channel tool feedback finalization")
|
|
}
|
|
}
|
|
|
|
func TestPreSend_ToolFeedbackSeparateMessagesDeletesPlaceholderAndSkipsEdit(t *testing.T) {
|
|
m := newTestManager()
|
|
m.config = &config.Config{
|
|
Agents: config.AgentsConfig{
|
|
Defaults: config.AgentDefaults{
|
|
ToolFeedback: config.ToolFeedbackConfig{
|
|
Enabled: true,
|
|
SeparateMessages: true,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
ch := &mockDeletingMessageEditor{
|
|
mockMessageEditor: mockMessageEditor{
|
|
editFn: func(_ context.Context, _, _, _ string) error {
|
|
t.Fatal("expected placeholder edit to be skipped in separate message mode")
|
|
return nil
|
|
},
|
|
},
|
|
}
|
|
|
|
m.RecordPlaceholder("test", "123", "456")
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Content: "hello",
|
|
Context: bus.InboundContext{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Raw: map[string]string{
|
|
"message_kind": "tool_feedback",
|
|
},
|
|
},
|
|
})
|
|
|
|
msgIDs, handled := m.preSend(context.Background(), "test", msg, ch)
|
|
if handled {
|
|
t.Fatalf("expected preSend to fall through so the channel can send a new message, got %v", msgIDs)
|
|
}
|
|
if ch.deleteCalls != 1 {
|
|
t.Fatalf("expected placeholder deletion, got %d delete calls", ch.deleteCalls)
|
|
}
|
|
if ch.deletedChatID != "123" || ch.deletedMessageID != "456" {
|
|
t.Fatalf("unexpected placeholder deletion target: %s/%s", ch.deletedChatID, ch.deletedMessageID)
|
|
}
|
|
if ch.recordedMessageID != "" {
|
|
t.Fatalf("expected no tracked placeholder record, got %q", ch.recordedMessageID)
|
|
}
|
|
if ch.clearedChatID != "123" {
|
|
t.Fatalf("expected tracked tool feedback state to be cleared before sending, got %q", ch.clearedChatID)
|
|
}
|
|
}
|
|
|
|
func TestPreSend_NonToolFeedbackSeparateMessagesClearsTrackedMessageWithoutDismiss(t *testing.T) {
|
|
m := newTestManager()
|
|
m.config = &config.Config{
|
|
Agents: config.AgentsConfig{
|
|
Defaults: config.AgentDefaults{
|
|
ToolFeedback: config.ToolFeedbackConfig{
|
|
Enabled: true,
|
|
SeparateMessages: true,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
ch := &mockMessageEditor{}
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Content: "final reply",
|
|
Context: bus.InboundContext{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
},
|
|
})
|
|
|
|
_, handled := m.preSend(context.Background(), "test", msg, ch)
|
|
if handled {
|
|
t.Fatal("expected preSend to leave final delivery to the channel")
|
|
}
|
|
if ch.clearedChatID != "123" {
|
|
t.Fatalf("expected tracked tool feedback state to be cleared, got %q", ch.clearedChatID)
|
|
}
|
|
if ch.dismissedChatID != "" {
|
|
t.Fatalf("expected tracked tool feedback message to be preserved, got dismissal for %q", ch.dismissedChatID)
|
|
}
|
|
if ch.finalizeCalled {
|
|
t.Fatal("expected separate message mode to skip in-place finalization")
|
|
}
|
|
}
|
|
|
|
func TestPreSend_StaleToolFeedbackDoesNotConsumeStreamActiveMarker(t *testing.T) {
|
|
m := newTestManager()
|
|
m.streamActive.Store("test:123", true)
|
|
m.RecordPlaceholder("test", "123", "placeholder-1")
|
|
|
|
var editedContent string
|
|
ch := &mockMessageEditor{
|
|
editFn: func(_ context.Context, chatID, messageID, content string) error {
|
|
if chatID != "123" || messageID != "placeholder-1" {
|
|
t.Fatalf("unexpected edit target: %s/%s", chatID, messageID)
|
|
}
|
|
editedContent = content
|
|
return nil
|
|
},
|
|
}
|
|
|
|
toolFeedback := testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Content: "🔧 `read_file`\nReading config",
|
|
Context: bus.InboundContext{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Raw: map[string]string{
|
|
"message_kind": "tool_feedback",
|
|
},
|
|
},
|
|
})
|
|
|
|
msgIDs, handled := m.preSend(context.Background(), "test", toolFeedback, ch)
|
|
if !handled {
|
|
t.Fatal("expected stale tool feedback to be dropped after stream finalize")
|
|
}
|
|
if len(msgIDs) != 0 {
|
|
t.Fatalf("expected no delivered message IDs for stale feedback, got %v", msgIDs)
|
|
}
|
|
if _, ok := m.streamActive.Load("test:123"); !ok {
|
|
t.Fatal("expected streamActive marker to remain for the final outbound message")
|
|
}
|
|
if _, ok := m.placeholders.Load("test:123"); !ok {
|
|
t.Fatal("expected placeholder cleanup to remain deferred to the final outbound message")
|
|
}
|
|
if ch.editedMessages != 0 {
|
|
t.Fatalf("expected no placeholder edit for stale feedback, got %d edits", ch.editedMessages)
|
|
}
|
|
|
|
finalMsg := testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Content: "final streamed reply",
|
|
Context: bus.InboundContext{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
},
|
|
})
|
|
|
|
_, handled = m.preSend(context.Background(), "test", finalMsg, ch)
|
|
if !handled {
|
|
t.Fatal("expected final outbound message to consume streamActive marker")
|
|
}
|
|
if _, ok := m.streamActive.Load("test:123"); ok {
|
|
t.Fatal("expected streamActive marker to be cleared by final outbound message")
|
|
}
|
|
if _, ok := m.placeholders.Load("test:123"); ok {
|
|
t.Fatal("expected placeholder to be cleaned up by final outbound message")
|
|
}
|
|
if editedContent != "final streamed reply" {
|
|
t.Fatalf("editedContent = %q, want final streamed reply", editedContent)
|
|
}
|
|
}
|
|
|
|
func TestPreSendMedia_LeavesTrackedMessageForChannelSend(t *testing.T) {
|
|
m := newTestManager()
|
|
ch := &mockDeletingMediaChannel{}
|
|
|
|
m.preSendMedia(context.Background(), "test", bus.OutboundMediaMessage{
|
|
ChatID: "123",
|
|
Context: bus.InboundContext{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
},
|
|
}, ch)
|
|
|
|
if ch.dismissedChatID != "" {
|
|
t.Fatalf(
|
|
"expected tracked tool feedback cleanup to be deferred to channel media send, got %q",
|
|
ch.dismissedChatID,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestPreSendMedia_SeparateMessagesClearsTrackedMessageWithoutDismiss(t *testing.T) {
|
|
m := newTestManager()
|
|
m.config = &config.Config{
|
|
Agents: config.AgentsConfig{
|
|
Defaults: config.AgentDefaults{
|
|
ToolFeedback: config.ToolFeedbackConfig{
|
|
Enabled: true,
|
|
SeparateMessages: true,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
ch := &mockMessageEditor{}
|
|
|
|
m.preSendMedia(context.Background(), "test", bus.OutboundMediaMessage{
|
|
ChatID: "123",
|
|
Context: bus.InboundContext{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
},
|
|
}, ch)
|
|
|
|
if ch.clearedChatID != "123" {
|
|
t.Fatalf("expected tracked tool feedback state to be cleared before media delivery, got %q", ch.clearedChatID)
|
|
}
|
|
if ch.dismissedChatID != "" {
|
|
t.Fatalf("expected tracked tool feedback message to be preserved"+
|
|
" for media delivery, got %q", ch.dismissedChatID)
|
|
}
|
|
}
|
|
|
|
func TestSplitOutboundMessageContent_ToolFeedbackTruncatesInsteadOfSplitting(t *testing.T) {
|
|
msg := testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Content: "\U0001f527 `read_file`\nRead README.md first to confirm the current project structure before editing the config example.",
|
|
Context: bus.InboundContext{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Raw: map[string]string{
|
|
"message_kind": "tool_feedback",
|
|
},
|
|
},
|
|
})
|
|
|
|
chunks := splitOutboundMessageContent(msg, 40)
|
|
if len(chunks) != 1 {
|
|
t.Fatalf("len(chunks) = %d, want 1", len(chunks))
|
|
}
|
|
want := utils.FitToolFeedbackMessage(msg.Content, 40-MaxToolFeedbackAnimationFrameLength())
|
|
if chunks[0] != want {
|
|
t.Fatalf("chunk = %q, want %q", chunks[0], want)
|
|
}
|
|
}
|
|
|
|
func TestSplitOutboundMessageContent_ToolFeedbackReservesAnimationFrame(t *testing.T) {
|
|
msg := testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Content: "🔧 `read_file`\n1234567890",
|
|
Context: bus.InboundContext{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Raw: map[string]string{
|
|
"message_kind": "tool_feedback",
|
|
},
|
|
},
|
|
})
|
|
|
|
chunks := splitOutboundMessageContent(msg, len([]rune(msg.Content)))
|
|
if len(chunks) != 1 {
|
|
t.Fatalf("len(chunks) = %d, want 1", len(chunks))
|
|
}
|
|
|
|
animated := formatAnimatedToolFeedbackContent(chunks[0], strings.Repeat(".", MaxToolFeedbackAnimationFrameLength()))
|
|
if got, maxLen := len([]rune(animated)), len([]rune(msg.Content)); got > maxLen {
|
|
t.Fatalf("animated len = %d, want <= %d; content=%q", got, maxLen, animated)
|
|
}
|
|
}
|
|
|
|
func TestGetStreamer_FinalizeDismissesTrackedToolFeedback(t *testing.T) {
|
|
m := newTestManager()
|
|
ch := &mockStreamingChannel{
|
|
mockMessageEditor: mockMessageEditor{},
|
|
streamer: &mockStreamer{
|
|
finalizeFn: func(_ context.Context, content string) error {
|
|
if content != "final reply" {
|
|
t.Fatalf("unexpected finalize content: %q", content)
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
}
|
|
m.channels["test"] = ch
|
|
|
|
streamer, ok := m.GetStreamer(context.Background(), "test", "123")
|
|
if !ok {
|
|
t.Fatal("expected streamer to be available")
|
|
}
|
|
if err := streamer.Finalize(context.Background(), "final reply"); err != nil {
|
|
t.Fatalf("Finalize() error = %v", err)
|
|
}
|
|
if ch.dismissedChatID != "123" {
|
|
t.Fatalf("expected tracked tool feedback to be dismissed for chat 123, got %q", ch.dismissedChatID)
|
|
}
|
|
if _, ok := m.streamActive.Load("test:123"); !ok {
|
|
t.Fatal("expected streamActive marker to be recorded after finalize")
|
|
}
|
|
}
|
|
|
|
func TestGetStreamer_FinalizeSeparateMessagesClearsTrackedToolFeedback(t *testing.T) {
|
|
m := newTestManager()
|
|
m.config = &config.Config{
|
|
Agents: config.AgentsConfig{
|
|
Defaults: config.AgentDefaults{
|
|
ToolFeedback: config.ToolFeedbackConfig{
|
|
Enabled: true,
|
|
SeparateMessages: true,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
ch := &mockStreamingChannel{
|
|
mockMessageEditor: mockMessageEditor{},
|
|
streamer: &mockStreamer{
|
|
finalizeFn: func(_ context.Context, content string) error {
|
|
if content != "final reply" {
|
|
t.Fatalf("unexpected finalize content: %q", content)
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
}
|
|
m.channels["test"] = ch
|
|
|
|
streamer, ok := m.GetStreamer(context.Background(), "test", "123")
|
|
if !ok {
|
|
t.Fatal("expected streamer to be available")
|
|
}
|
|
if err := streamer.Finalize(context.Background(), "final reply"); err != nil {
|
|
t.Fatalf("Finalize() error = %v", err)
|
|
}
|
|
if ch.clearedChatID != "123" {
|
|
t.Fatalf("expected tracked tool feedback to be cleared for chat 123, got %q", ch.clearedChatID)
|
|
}
|
|
if ch.dismissedChatID != "" {
|
|
t.Fatalf("expected tracked tool feedback message to be preserved, got dismissal for %q", ch.dismissedChatID)
|
|
}
|
|
if _, ok := m.streamActive.Load("test:123"); !ok {
|
|
t.Fatal("expected streamActive marker to be recorded after finalize")
|
|
}
|
|
}
|
|
|
|
func TestGetStreamer_FinalizeDismissesResolvedTrackedToolFeedback(t *testing.T) {
|
|
m := newTestManager()
|
|
ch := &mockStreamingChannel{
|
|
mockMessageEditor: mockMessageEditor{},
|
|
streamer: &mockStreamer{
|
|
finalizeFn: func(_ context.Context, content string) error {
|
|
if content != "final reply" {
|
|
t.Fatalf("unexpected finalize content: %q", content)
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
resolveChatIDFn: func(chatID string, outboundCtx *bus.InboundContext) string {
|
|
if outboundCtx == nil {
|
|
t.Fatal("expected outbound context during stream finalize")
|
|
}
|
|
if outboundCtx.ChatID != "-100123/42" {
|
|
t.Fatalf("unexpected outbound context: %+v", outboundCtx)
|
|
}
|
|
return outboundCtx.ChatID
|
|
},
|
|
}
|
|
m.channels["test"] = ch
|
|
|
|
streamer, ok := m.GetStreamer(context.Background(), "test", "-100123/42")
|
|
if !ok {
|
|
t.Fatal("expected streamer to be available")
|
|
}
|
|
if err := streamer.Finalize(context.Background(), "final reply"); err != nil {
|
|
t.Fatalf("Finalize() error = %v", err)
|
|
}
|
|
if ch.dismissedChatID != "-100123/42" {
|
|
t.Fatalf("expected resolved tracked tool feedback dismissal, got %q", ch.dismissedChatID)
|
|
}
|
|
if _, ok := m.streamActive.Load("test:-100123/42"); !ok {
|
|
t.Fatal("expected streamActive marker to be recorded after finalize")
|
|
}
|
|
}
|
|
|
|
func TestPreSend_PlaceholderEditSuccessDismissesResolvedTrackedToolFeedback(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
ch := &mockResolvedToolFeedbackEditor{
|
|
mockMessageEditor: mockMessageEditor{
|
|
editFn: func(_ context.Context, chatID, messageID, content string) error {
|
|
if chatID != "-100123" || messageID != "456" || content != "done" {
|
|
t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content)
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
resolveChatIDFn: func(chatID string, outboundCtx *bus.InboundContext) string {
|
|
if outboundCtx == nil || outboundCtx.TopicID != "42" {
|
|
t.Fatalf("expected topic-aware outbound context, got %+v", outboundCtx)
|
|
}
|
|
return chatID + "/" + outboundCtx.TopicID
|
|
},
|
|
}
|
|
|
|
m.RecordPlaceholder("test", "-100123", "456")
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "test",
|
|
ChatID: "-100123",
|
|
Content: "done",
|
|
Context: bus.InboundContext{
|
|
Channel: "test",
|
|
ChatID: "-100123",
|
|
TopicID: "42",
|
|
},
|
|
})
|
|
|
|
_, edited := m.preSend(context.Background(), "test", msg, ch)
|
|
if !edited {
|
|
t.Fatal("expected preSend to edit placeholder")
|
|
}
|
|
if ch.dismissedChatID != "-100123/42" {
|
|
t.Fatalf("expected resolved tracked dismissal, got %q", ch.dismissedChatID)
|
|
}
|
|
}
|
|
|
|
func TestGetStreamer_FinalizeFailureDoesNotDismissTrackedToolFeedback(t *testing.T) {
|
|
m := newTestManager()
|
|
ch := &mockStreamingChannel{
|
|
mockMessageEditor: mockMessageEditor{},
|
|
streamer: &mockStreamer{
|
|
finalizeFn: func(context.Context, string) error {
|
|
return errors.New("finalize failed")
|
|
},
|
|
},
|
|
}
|
|
m.channels["test"] = ch
|
|
|
|
streamer, ok := m.GetStreamer(context.Background(), "test", "123")
|
|
if !ok {
|
|
t.Fatal("expected streamer to be available")
|
|
}
|
|
if err := streamer.Finalize(context.Background(), "final reply"); err == nil {
|
|
t.Fatal("expected Finalize() to fail")
|
|
}
|
|
if ch.dismissedChatID != "" {
|
|
t.Fatalf("expected no tool feedback dismissal on finalize failure, got %q", ch.dismissedChatID)
|
|
}
|
|
if _, ok := m.streamActive.Load("test:123"); ok {
|
|
t.Fatal("expected no streamActive marker after finalize failure")
|
|
}
|
|
}
|
|
|
|
func TestRunWorker_ToolFeedbackSkipsMarkerSplitting(t *testing.T) {
|
|
m := newTestManager()
|
|
m.config = &config.Config{
|
|
Agents: config.AgentsConfig{
|
|
Defaults: config.AgentDefaults{
|
|
SplitOnMarker: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
received []string
|
|
)
|
|
ch := &mockChannelWithLength{
|
|
mockChannel: mockChannel{
|
|
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
|
|
mu.Lock()
|
|
received = append(received, msg.Content)
|
|
mu.Unlock()
|
|
return nil
|
|
},
|
|
},
|
|
maxLen: 200,
|
|
}
|
|
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
queue: make(chan bus.OutboundMessage, 1),
|
|
done: make(chan struct{}),
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go m.runWorker(ctx, "test", w)
|
|
|
|
content := "🔧 `read_file`\nRead current config first.<|[SPLIT]|>Then update the example."
|
|
w.queue <- testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Content: content,
|
|
Context: bus.InboundContext{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Raw: map[string]string{
|
|
"message_kind": "tool_feedback",
|
|
},
|
|
},
|
|
})
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(received) != 1 {
|
|
t.Fatalf("len(received) = %d, want 1", len(received))
|
|
}
|
|
if received[0] != content {
|
|
t.Fatalf("received[0] = %q, want %q", received[0], content)
|
|
}
|
|
}
|
|
|
|
func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
ch := &mockMessageEditor{
|
|
mockChannel: mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
return nil
|
|
},
|
|
},
|
|
editFn: func(_ context.Context, _, _, _ string) error {
|
|
return fmt.Errorf("edit failed")
|
|
},
|
|
}
|
|
|
|
m.RecordPlaceholder("test", "123", "456")
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"})
|
|
_, edited := m.preSend(context.Background(), "test", msg, ch)
|
|
|
|
if edited {
|
|
t.Fatal("expected preSend to return false when edit fails")
|
|
}
|
|
}
|
|
|
|
func TestInvokeTypingStop_CallsRegisteredStop(t *testing.T) {
|
|
m := newTestManager()
|
|
var stopCalled bool
|
|
|
|
m.RecordTypingStop("telegram", "chat123", func() {
|
|
stopCalled = true
|
|
})
|
|
|
|
m.InvokeTypingStop("telegram", "chat123")
|
|
|
|
if !stopCalled {
|
|
t.Fatal("expected typing stop func to be called")
|
|
}
|
|
}
|
|
|
|
func TestInvokeTypingStop_NoOpWhenNoEntry(t *testing.T) {
|
|
m := newTestManager()
|
|
// Should not panic
|
|
m.InvokeTypingStop("telegram", "nonexistent")
|
|
}
|
|
|
|
func TestInvokeTypingStop_Idempotent(t *testing.T) {
|
|
m := newTestManager()
|
|
var callCount int
|
|
|
|
m.RecordTypingStop("telegram", "chat123", func() {
|
|
callCount++
|
|
})
|
|
|
|
m.InvokeTypingStop("telegram", "chat123")
|
|
m.InvokeTypingStop("telegram", "chat123") // Second call: entry already removed, no-op
|
|
|
|
if callCount != 1 {
|
|
t.Fatalf("expected stop to be called once, got %d", callCount)
|
|
}
|
|
}
|
|
|
|
func TestPreSend_TypingStopCalled(t *testing.T) {
|
|
m := newTestManager()
|
|
var stopCalled bool
|
|
|
|
ch := &mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
return nil
|
|
},
|
|
}
|
|
|
|
m.RecordTypingStop("test", "123", func() {
|
|
stopCalled = true
|
|
})
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"})
|
|
m.preSend(context.Background(), "test", msg, ch)
|
|
|
|
if !stopCalled {
|
|
t.Fatal("expected typing stop func to be called")
|
|
}
|
|
}
|
|
|
|
func TestPreSend_NoRegisteredState(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
ch := &mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
return nil
|
|
},
|
|
}
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"})
|
|
_, edited := m.preSend(context.Background(), "test", msg, ch)
|
|
|
|
if edited {
|
|
t.Fatal("expected preSend to return false with no registered state")
|
|
}
|
|
}
|
|
|
|
func TestPreSend_TypingAndPlaceholder(t *testing.T) {
|
|
m := newTestManager()
|
|
var stopCalled bool
|
|
var editCalled bool
|
|
|
|
ch := &mockMessageEditor{
|
|
mockChannel: mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
return nil
|
|
},
|
|
},
|
|
editFn: func(_ context.Context, _, _, _ string) error {
|
|
editCalled = true
|
|
return nil
|
|
},
|
|
}
|
|
|
|
m.RecordTypingStop("test", "123", func() {
|
|
stopCalled = true
|
|
})
|
|
m.RecordPlaceholder("test", "123", "456")
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"})
|
|
_, edited := m.preSend(context.Background(), "test", msg, ch)
|
|
|
|
if !stopCalled {
|
|
t.Fatal("expected typing stop to be called")
|
|
}
|
|
if !editCalled {
|
|
t.Fatal("expected EditMessage to be called")
|
|
}
|
|
if !edited {
|
|
t.Fatal("expected preSend to return true")
|
|
}
|
|
}
|
|
|
|
func TestRecordPlaceholder_ConcurrentSafe(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
var wg sync.WaitGroup
|
|
for i := range 100 {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
chatID := fmt.Sprintf("chat_%d", i%10)
|
|
m.RecordPlaceholder("test", chatID, fmt.Sprintf("msg_%d", i))
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
func TestRecordTypingStop_ConcurrentSafe(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
var wg sync.WaitGroup
|
|
for i := range 100 {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
chatID := fmt.Sprintf("chat_%d", i%10)
|
|
m.RecordTypingStop("test", chatID, func() {})
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
func TestRecordTypingStop_ReplacesExistingStop(t *testing.T) {
|
|
m := newTestManager()
|
|
var oldStopCalls int
|
|
var newStopCalls int
|
|
|
|
m.RecordTypingStop("test", "123", func() {
|
|
oldStopCalls++
|
|
})
|
|
|
|
m.RecordTypingStop("test", "123", func() {
|
|
newStopCalls++
|
|
})
|
|
|
|
if oldStopCalls != 1 {
|
|
t.Fatalf("expected previous typing stop to be called once when replaced, got %d", oldStopCalls)
|
|
}
|
|
if newStopCalls != 0 {
|
|
t.Fatalf("expected replacement typing stop to stay active until preSend, got %d calls", newStopCalls)
|
|
}
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"})
|
|
m.preSend(context.Background(), "test", msg, &mockChannel{})
|
|
|
|
if newStopCalls != 1 {
|
|
t.Fatalf("expected replacement typing stop to be called by preSend, got %d", newStopCalls)
|
|
}
|
|
if oldStopCalls != 1 {
|
|
t.Fatalf("expected previous typing stop to not be called again, got %d", oldStopCalls)
|
|
}
|
|
}
|
|
|
|
func TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) {
|
|
m := newTestManager()
|
|
var sendCalled bool
|
|
|
|
ch := &mockMessageEditor{
|
|
mockChannel: mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
sendCalled = true
|
|
return nil
|
|
},
|
|
},
|
|
editFn: func(_ context.Context, _, _, _ string) error {
|
|
return nil // edit succeeds
|
|
},
|
|
}
|
|
|
|
m.RecordPlaceholder("test", "123", "456")
|
|
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"})
|
|
m.sendWithRetry(context.Background(), "test", w, msg)
|
|
|
|
if sendCalled {
|
|
t.Fatal("expected Send to NOT be called when placeholder was edited")
|
|
}
|
|
}
|
|
|
|
// --- Dispatcher exit tests (Step 1) ---
|
|
|
|
func TestDispatcherExitsOnCancel(t *testing.T) {
|
|
mb := bus.NewMessageBus()
|
|
defer mb.Close()
|
|
|
|
m := &Manager{
|
|
channels: make(map[string]Channel),
|
|
workers: make(map[string]*channelWorker),
|
|
bus: mb,
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
|
|
go func() {
|
|
m.dispatchOutbound(ctx)
|
|
close(done)
|
|
}()
|
|
|
|
// Cancel context and verify the dispatcher exits quickly
|
|
cancel()
|
|
|
|
select {
|
|
case <-done:
|
|
// success
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("dispatchOutbound did not exit within 2s after context cancel")
|
|
}
|
|
}
|
|
|
|
func TestDispatcherMediaExitsOnCancel(t *testing.T) {
|
|
mb := bus.NewMessageBus()
|
|
defer mb.Close()
|
|
|
|
m := &Manager{
|
|
channels: make(map[string]Channel),
|
|
workers: make(map[string]*channelWorker),
|
|
bus: mb,
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
|
|
go func() {
|
|
m.dispatchOutboundMedia(ctx)
|
|
close(done)
|
|
}()
|
|
|
|
cancel()
|
|
|
|
select {
|
|
case <-done:
|
|
// success
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("dispatchOutboundMedia did not exit within 2s after context cancel")
|
|
}
|
|
}
|
|
|
|
// --- TTL Janitor tests (Step 2) ---
|
|
|
|
func TestTypingStopJanitorEviction(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
var stopCalled atomic.Bool
|
|
// Store a typing entry with a creation time far in the past
|
|
m.typingStops.Store("test:123", typingEntry{
|
|
stop: func() { stopCalled.Store(true) },
|
|
createdAt: time.Now().Add(-10 * time.Minute), // well past typingStopTTL
|
|
})
|
|
|
|
// Run janitor with a short-lived context
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
// Manually trigger the janitor logic once by simulating a tick
|
|
go func() {
|
|
// Override janitor to run immediately
|
|
now := time.Now()
|
|
m.typingStops.Range(func(key, value any) bool {
|
|
if entry, ok := value.(typingEntry); ok {
|
|
if now.Sub(entry.createdAt) > typingStopTTL {
|
|
if _, loaded := m.typingStops.LoadAndDelete(key); loaded {
|
|
entry.stop()
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
cancel()
|
|
}()
|
|
|
|
<-ctx.Done()
|
|
|
|
if !stopCalled.Load() {
|
|
t.Fatal("expected typing stop function to be called by janitor eviction")
|
|
}
|
|
|
|
// Verify entry was deleted
|
|
if _, loaded := m.typingStops.Load("test:123"); loaded {
|
|
t.Fatal("expected typing entry to be deleted after eviction")
|
|
}
|
|
}
|
|
|
|
func TestPlaceholderJanitorEviction(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
// Store a placeholder entry with a creation time far in the past
|
|
m.placeholders.Store("test:456", placeholderEntry{
|
|
id: "msg_old",
|
|
createdAt: time.Now().Add(-20 * time.Minute), // well past placeholderTTL
|
|
})
|
|
|
|
// Simulate janitor logic
|
|
now := time.Now()
|
|
m.placeholders.Range(func(key, value any) bool {
|
|
if entry, ok := value.(placeholderEntry); ok {
|
|
if now.Sub(entry.createdAt) > placeholderTTL {
|
|
m.placeholders.Delete(key)
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
|
|
// Verify entry was deleted
|
|
if _, loaded := m.placeholders.Load("test:456"); loaded {
|
|
t.Fatal("expected placeholder entry to be deleted after eviction")
|
|
}
|
|
}
|
|
|
|
func TestPreSendStillWorksWithWrappedTypes(t *testing.T) {
|
|
m := newTestManager()
|
|
var stopCalled bool
|
|
var editCalled bool
|
|
|
|
ch := &mockMessageEditor{
|
|
mockChannel: mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
return nil
|
|
},
|
|
},
|
|
editFn: func(_ context.Context, chatID, messageID, content string) error {
|
|
editCalled = true
|
|
if messageID != "ph_id" {
|
|
t.Fatalf("expected messageID ph_id, got %s", messageID)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// Use the new wrapped types via the public API
|
|
m.RecordTypingStop("test", "chat1", func() {
|
|
stopCalled = true
|
|
})
|
|
m.RecordPlaceholder("test", "chat1", "ph_id")
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "chat1", Content: "response"})
|
|
_, edited := m.preSend(context.Background(), "test", msg, ch)
|
|
|
|
if !stopCalled {
|
|
t.Fatal("expected typing stop to be called via wrapped type")
|
|
}
|
|
if !editCalled {
|
|
t.Fatal("expected EditMessage to be called via wrapped type")
|
|
}
|
|
if !edited {
|
|
t.Fatal("expected preSend to return true")
|
|
}
|
|
}
|
|
|
|
// --- Lazy worker creation tests (Step 6) ---
|
|
|
|
func TestLazyWorkerCreation(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
ch := &mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// RegisterChannel should NOT create a worker
|
|
m.RegisterChannel("lazy", ch)
|
|
|
|
m.mu.RLock()
|
|
_, chExists := m.channels["lazy"]
|
|
_, wExists := m.workers["lazy"]
|
|
m.mu.RUnlock()
|
|
|
|
if !chExists {
|
|
t.Fatal("expected channel to be registered")
|
|
}
|
|
if wExists {
|
|
t.Fatal("expected worker to NOT be created by RegisterChannel (lazy creation)")
|
|
}
|
|
}
|
|
|
|
// --- FastID uniqueness test (Step 5) ---
|
|
|
|
func TestBuildMediaScope_FastIDUniqueness(t *testing.T) {
|
|
seen := make(map[string]bool)
|
|
|
|
for range 1000 {
|
|
scope := BuildMediaScope("test", "chat1", "")
|
|
if seen[scope] {
|
|
t.Fatalf("duplicate scope generated: %s", scope)
|
|
}
|
|
seen[scope] = true
|
|
}
|
|
|
|
// Verify format: "channel:chatID:id"
|
|
scope := BuildMediaScope("telegram", "42", "")
|
|
parts := 0
|
|
for _, c := range scope {
|
|
if c == ':' {
|
|
parts++
|
|
}
|
|
}
|
|
if parts != 2 {
|
|
t.Fatalf("expected scope to have 2 colons (channel:chatID:id), got: %s", scope)
|
|
}
|
|
}
|
|
|
|
func TestBuildMediaScope_WithMessageID(t *testing.T) {
|
|
scope := BuildMediaScope("discord", "chat99", "msg123")
|
|
expected := "discord:chat99:msg123"
|
|
if scope != expected {
|
|
t.Fatalf("expected %s, got %s", expected, scope)
|
|
}
|
|
}
|
|
|
|
func TestManager_PlaceholderConsumedByResponse(t *testing.T) {
|
|
mgr := &Manager{
|
|
channels: make(map[string]Channel),
|
|
workers: make(map[string]*channelWorker),
|
|
placeholders: sync.Map{},
|
|
}
|
|
|
|
mockCh := &mockChannel{
|
|
sendFn: func(ctx context.Context, msg bus.OutboundMessage) error {
|
|
return nil
|
|
},
|
|
}
|
|
worker := newChannelWorker("mock", mockCh, "mock")
|
|
mgr.channels["mock"] = mockCh
|
|
mgr.workers["mock"] = worker
|
|
|
|
ctx := context.Background()
|
|
key := "mock:chat-1"
|
|
|
|
// Simulate a placeholder recorded by base.go HandleMessage
|
|
mgr.RecordPlaceholder("mock", "chat-1", "ph-123")
|
|
|
|
if _, ok := mgr.placeholders.Load(key); !ok {
|
|
t.Fatal("expected placeholder to be recorded")
|
|
}
|
|
|
|
// Transcription feedback arrives first — it should consume the placeholder
|
|
// and be delivered via EditMessage, not Send.
|
|
msgTranscript := testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "mock",
|
|
ChatID: "chat-1",
|
|
Content: "Transcript: hello",
|
|
})
|
|
mgr.sendWithRetry(ctx, "mock", worker, msgTranscript)
|
|
|
|
if mockCh.editedMessages != 1 {
|
|
t.Errorf("expected 1 edited message (placeholder consumed by transcript), got %d", mockCh.editedMessages)
|
|
}
|
|
if len(mockCh.sentMessages) != 0 {
|
|
t.Errorf("expected 0 normal messages (transcript used edit), got %d", len(mockCh.sentMessages))
|
|
}
|
|
|
|
// Placeholder should be gone now
|
|
if _, ok := mgr.placeholders.Load(key); ok {
|
|
t.Error("expected placeholder to be removed after being consumed")
|
|
}
|
|
|
|
// Final LLM response arrives — no placeholder left, so it goes through Send
|
|
msgFinal := testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "mock",
|
|
ChatID: "chat-1",
|
|
Content: "Final Answer",
|
|
})
|
|
mgr.sendWithRetry(ctx, "mock", worker, msgFinal)
|
|
|
|
if len(mockCh.sentMessages) != 1 {
|
|
t.Errorf("expected 1 normal message sent, got %d", len(mockCh.sentMessages))
|
|
}
|
|
}
|
|
|
|
func TestSendMessage_Synchronous(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
var received []bus.OutboundMessage
|
|
ch := &mockChannel{
|
|
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
|
|
received = append(received, msg)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
m.channels["test"] = ch
|
|
m.workers["test"] = w
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Content: "hello world",
|
|
ReplyToMessageID: "msg-456",
|
|
})
|
|
|
|
err := m.SendMessage(context.Background(), msg)
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
|
|
// SendMessage is synchronous — message should already be delivered
|
|
if len(received) != 1 {
|
|
t.Fatalf("expected 1 message sent, got %d", len(received))
|
|
}
|
|
if received[0].ReplyToMessageID != "msg-456" {
|
|
t.Fatalf("expected ReplyToMessageID msg-456, got %s", received[0].ReplyToMessageID)
|
|
}
|
|
if received[0].Content != "hello world" {
|
|
t.Fatalf("expected content 'hello world', got %s", received[0].Content)
|
|
}
|
|
}
|
|
|
|
func TestSendMessage_UnknownChannel(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "nonexistent",
|
|
ChatID: "123",
|
|
Content: "hello",
|
|
})
|
|
|
|
err := m.SendMessage(context.Background(), msg)
|
|
if err == nil {
|
|
t.Fatal("expected error for unknown channel")
|
|
}
|
|
}
|
|
|
|
func TestSendMessage_NoWorker(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
ch := &mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
|
|
}
|
|
m.channels["test"] = ch
|
|
// No worker registered
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Content: "hello",
|
|
})
|
|
|
|
err := m.SendMessage(context.Background(), msg)
|
|
if err == nil {
|
|
t.Fatal("expected error when no worker exists")
|
|
}
|
|
}
|
|
|
|
func TestSendMessage_WithRetry(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
var callCount int
|
|
ch := &mockChannel{
|
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
|
callCount++
|
|
if callCount == 1 {
|
|
return fmt.Errorf("transient: %w", ErrTemporary)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
m.channels["test"] = ch
|
|
m.workers["test"] = w
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Content: "retry me",
|
|
})
|
|
|
|
err := m.SendMessage(context.Background(), msg)
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
|
|
if callCount != 2 {
|
|
t.Fatalf("expected 2 Send calls (1 failure + 1 success), got %d", callCount)
|
|
}
|
|
}
|
|
|
|
func TestSendMessage_ContextOnlyUsesContextAddressing(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
var received []bus.OutboundMessage
|
|
ch := &mockChannel{
|
|
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
|
|
received = append(received, msg)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
m.channels["test"] = ch
|
|
m.workers["test"] = w
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{
|
|
Context: bus.NewOutboundContext("test", "123", "msg-9"),
|
|
Content: "hello",
|
|
})
|
|
|
|
if err := m.SendMessage(context.Background(), msg); err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if len(received) != 1 {
|
|
t.Fatalf("expected 1 message sent, got %d", len(received))
|
|
}
|
|
if received[0].Channel != "test" || received[0].ChatID != "123" {
|
|
t.Fatalf("expected mirrored legacy address, got %+v", received[0])
|
|
}
|
|
if received[0].Context.Channel != "test" || received[0].Context.ChatID != "123" {
|
|
t.Fatalf("expected context address to be preserved, got %+v", received[0].Context)
|
|
}
|
|
if received[0].ReplyToMessageID != "msg-9" {
|
|
t.Fatalf("expected reply_to_message_id msg-9, got %q", received[0].ReplyToMessageID)
|
|
}
|
|
}
|
|
|
|
func TestSendMessage_WithSplitting(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
var received []string
|
|
ch := &mockChannelWithLength{
|
|
mockChannel: mockChannel{
|
|
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
|
|
received = append(received, msg.Content)
|
|
return nil
|
|
},
|
|
},
|
|
maxLen: 5,
|
|
}
|
|
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
m.channels["test"] = ch
|
|
m.workers["test"] = w
|
|
|
|
msg := testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "test",
|
|
ChatID: "123",
|
|
Content: "hello world",
|
|
})
|
|
|
|
err := m.SendMessage(context.Background(), msg)
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
|
|
if len(received) < 2 {
|
|
t.Fatalf("expected message to be split into at least 2 chunks, got %d", len(received))
|
|
}
|
|
}
|
|
|
|
func TestSendMedia_ContextOnlyUsesContextAddressing(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
var received []bus.OutboundMediaMessage
|
|
ch := &mockMediaChannel{
|
|
sendMediaFn: func(_ context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
|
|
received = append(received, msg)
|
|
return nil, nil
|
|
},
|
|
}
|
|
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
m.channels["test"] = ch
|
|
m.workers["test"] = w
|
|
|
|
msg := testOutboundMediaMessage(bus.OutboundMediaMessage{
|
|
Context: bus.NewOutboundContext("test", "media-chat", ""),
|
|
Parts: []bus.MediaPart{{Type: "image", Ref: "media://1"}},
|
|
})
|
|
|
|
if err := m.SendMedia(context.Background(), msg); err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if len(received) != 1 {
|
|
t.Fatalf("expected 1 media message sent, got %d", len(received))
|
|
}
|
|
if received[0].Channel != "test" || received[0].ChatID != "media-chat" {
|
|
t.Fatalf("expected mirrored legacy media address, got %+v", received[0])
|
|
}
|
|
if received[0].Context.Channel != "test" || received[0].Context.ChatID != "media-chat" {
|
|
t.Fatalf("expected media context address to be preserved, got %+v", received[0].Context)
|
|
}
|
|
}
|
|
|
|
func TestSendMessage_PreservesOrdering(t *testing.T) {
|
|
m := newTestManager()
|
|
|
|
var order []string
|
|
ch := &mockChannel{
|
|
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
|
|
order = append(order, msg.Content)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
w := &channelWorker{
|
|
ch: ch,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
m.channels["test"] = ch
|
|
m.workers["test"] = w
|
|
|
|
// Send two messages sequentially — they must arrive in order
|
|
_ = m.SendMessage(context.Background(), testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "test", ChatID: "1", Content: "first",
|
|
}))
|
|
_ = m.SendMessage(context.Background(), testOutboundMessage(bus.OutboundMessage{
|
|
Channel: "test", ChatID: "1", Content: "second",
|
|
}))
|
|
|
|
if len(order) != 2 {
|
|
t.Fatalf("expected 2 messages, got %d", len(order))
|
|
}
|
|
if order[0] != "first" || order[1] != "second" {
|
|
t.Fatalf("expected [first, second], got %v", order)
|
|
}
|
|
}
|
|
|
|
func TestManager_SendPlaceholder(t *testing.T) {
|
|
mgr := &Manager{
|
|
channels: make(map[string]Channel),
|
|
workers: make(map[string]*channelWorker),
|
|
placeholders: sync.Map{},
|
|
}
|
|
|
|
mockCh := &mockChannel{
|
|
sendFn: func(ctx context.Context, msg bus.OutboundMessage) error {
|
|
return nil
|
|
},
|
|
}
|
|
mgr.channels["mock"] = mockCh
|
|
|
|
ctx := context.Background()
|
|
|
|
// SendPlaceholder should send a placeholder and record it
|
|
ok := mgr.SendPlaceholder(ctx, "mock", "chat-1")
|
|
if !ok {
|
|
t.Fatal("expected SendPlaceholder to succeed")
|
|
}
|
|
if mockCh.placeholdersSent != 1 {
|
|
t.Errorf("expected 1 placeholder sent, got %d", mockCh.placeholdersSent)
|
|
}
|
|
|
|
key := "mock:chat-1"
|
|
if _, loaded := mgr.placeholders.Load(key); !loaded {
|
|
t.Error("expected placeholder to be recorded in manager")
|
|
}
|
|
|
|
// SendPlaceholder on unknown channel should return false
|
|
ok = mgr.SendPlaceholder(ctx, "unknown", "chat-1")
|
|
if ok {
|
|
t.Error("expected SendPlaceholder to fail for unknown channel")
|
|
}
|
|
}
|