Files
DangTinh311 af43c952ed feat(bitrix24): agent activity indicator via InputAction.notify [B24:2794]
Show an ephemeral "agent is working" indicator (thinking/searching/
generating/analyzing…) in Bitrix24 chat while the agent processes, so
users on this non-streaming channel aren't left staring at silence
until the final reply. Send behavior is unchanged; no LLM call, no DB.

Generic layer (internal/channels):
- New optional ActivityIndicatorChannel interface.
- HandleAgentEvent routes run.started->THINKING, tool.call->mapped
  status, tool.result->ANALYZING; static tool->status mapping.
- Conditional heartbeat ticker fills LLM-inference gaps (re-sends only
  when idle), 5s per-run throttle caps call rate; ticker stopped on
  terminal events AND in UnregisterRun (safety net for missed terminals).

Bitrix24 (internal/channels/bitrix24):
- OnActivityEvent calls imbot.v2.Chat.InputAction.notify best-effort,
  drop-on-limit (raw Client.Call, no retry) so cosmetic notifies never
  steal leaky-bucket capacity from real message sends.
- Per-channel activity_indicator toggle (default on).

Web UI + docs: dashboard toggle, i18n en/vi/zh, channels doc section.
Tests: 26 unit tests incl. UnregisterRun-stops-ticker regression.
2026-07-14 15:28:14 +07:00

251 lines
7.6 KiB
Go

package bitrix24
import (
"context"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
)
// TestActivityIndicatorEnabled_Nil tests that nil config means enabled (default on).
func TestActivityIndicatorEnabled_Nil(t *testing.T) {
ch := &Channel{
cfg: bitrixInstanceConfig{
ActivityIndicator: nil, // default
},
}
if !ch.activityIndicatorEnabled() {
t.Errorf("activityIndicatorEnabled() with nil config should return true (default on)")
}
}
// TestActivityIndicatorEnabled_ExplicitTrue tests that *true enables the indicator.
func TestActivityIndicatorEnabled_ExplicitTrue(t *testing.T) {
trueVal := true
ch := &Channel{
cfg: bitrixInstanceConfig{
ActivityIndicator: &trueVal,
},
}
if !ch.activityIndicatorEnabled() {
t.Errorf("activityIndicatorEnabled() with *true should return true")
}
}
// TestActivityIndicatorEnabled_ExplicitFalse tests that *false disables the indicator.
func TestActivityIndicatorEnabled_ExplicitFalse(t *testing.T) {
falseVal := false
ch := &Channel{
cfg: bitrixInstanceConfig{
ActivityIndicator: &falseVal,
},
}
if ch.activityIndicatorEnabled() {
t.Errorf("activityIndicatorEnabled() with *false should return false")
}
}
// TestOnActivityEvent_DisabledReturnsNilNoCall tests that disabled indicator returns nil and makes no call.
func TestOnActivityEvent_DisabledReturnsNilNoCall(t *testing.T) {
falseVal := false
rt := &captureRT{}
ch := &Channel{
cfg: bitrixInstanceConfig{
ActivityIndicator: &falseVal,
},
BaseChannel: channels.NewBaseChannel("test", bus.New(), nil),
}
ch.client = newStubClient("test.bitrix24.com", rt)
err := ch.OnActivityEvent(context.Background(), "chat123", "IMBOT_AGENT_ACTION_THINKING")
if err != nil {
t.Errorf("OnActivityEvent with disabled indicator should return nil, got %v", err)
}
if len(rt.reqs) != 0 {
t.Errorf("disabled indicator should make no REST calls, made %d", len(rt.reqs))
}
}
// TestOnActivityEvent_EnabledMakesCall tests that enabled indicator makes a REST call.
func TestOnActivityEvent_EnabledMakesCall(t *testing.T) {
rt := &captureRT{}
ch := &Channel{
cfg: bitrixInstanceConfig{
ActivityIndicator: nil, // default: enabled
},
BaseChannel: channels.NewBaseChannel("test", bus.New(), nil),
}
ch.client = newStubClient("test.bitrix24.com", rt)
ch.botID = 123
err := ch.OnActivityEvent(context.Background(), "chat123", "IMBOT_AGENT_ACTION_THINKING")
if err != nil {
t.Errorf("OnActivityEvent with enabled indicator should return nil, got %v", err)
}
if len(rt.reqs) != 1 {
t.Errorf("enabled indicator should make 1 REST call, made %d", len(rt.reqs))
}
}
// TestOnActivityEvent_NilClientReturnsNil tests that nil client returns nil without calling.
func TestOnActivityEvent_NilClientReturnsNil(t *testing.T) {
rt := &captureRT{}
ch := &Channel{
cfg: bitrixInstanceConfig{
ActivityIndicator: nil, // enabled
},
BaseChannel: channels.NewBaseChannel("test", bus.New(), nil),
}
ch.client = nil // nil client
err := ch.OnActivityEvent(context.Background(), "chat123", "IMBOT_AGENT_ACTION_THINKING")
if err != nil {
t.Errorf("OnActivityEvent with nil client should return nil, got %v", err)
}
if len(rt.reqs) != 0 {
t.Errorf("nil client should make no REST calls, made %d", len(rt.reqs))
}
}
// TestOnActivityEvent_InvalidBotIDReturnsNil tests that botID <= 0 returns nil without calling.
func TestOnActivityEvent_InvalidBotIDReturnsNil(t *testing.T) {
rt := &captureRT{}
ch := &Channel{
cfg: bitrixInstanceConfig{
ActivityIndicator: nil, // enabled
},
BaseChannel: channels.NewBaseChannel("test", bus.New(), nil),
}
ch.client = newStubClient("test.bitrix24.com", rt)
ch.botID = 0 // invalid: <= 0
err := ch.OnActivityEvent(context.Background(), "chat123", "IMBOT_AGENT_ACTION_THINKING")
if err != nil {
t.Errorf("OnActivityEvent with invalid botID should return nil, got %v", err)
}
if len(rt.reqs) != 0 {
t.Errorf("invalid botID should make no REST calls, made %d", len(rt.reqs))
}
}
// TestOnActivityEvent_EmptyChatIDReturnsNil tests that empty chatID returns nil without calling.
func TestOnActivityEvent_EmptyChatIDReturnsNil(t *testing.T) {
rt := &captureRT{}
ch := &Channel{
cfg: bitrixInstanceConfig{
ActivityIndicator: nil, // enabled
},
BaseChannel: channels.NewBaseChannel("test", bus.New(), nil),
}
ch.client = newStubClient("test.bitrix24.com", rt)
ch.botID = 123
// Empty chatID should fail guard
err := ch.OnActivityEvent(context.Background(), "", "IMBOT_AGENT_ACTION_THINKING")
if err != nil {
t.Errorf("OnActivityEvent with empty chatID should return nil, got %v", err)
}
if len(rt.reqs) != 0 {
t.Errorf("empty chatID should make no REST calls, made %d", len(rt.reqs))
}
// Whitespace-only chatID should also fail guard
err = ch.OnActivityEvent(context.Background(), " \t ", "IMBOT_AGENT_ACTION_THINKING")
if err != nil {
t.Errorf("OnActivityEvent with whitespace chatID should return nil, got %v", err)
}
if len(rt.reqs) != 0 {
t.Errorf("whitespace chatID should make no REST calls, made %d", len(rt.reqs))
}
}
// TestOnActivityEvent_ValidCallIncludesParams tests that enabled/valid call includes all required params.
func TestOnActivityEvent_ValidCallIncludesParams(t *testing.T) {
rt := &captureRT{}
ch := &Channel{
cfg: bitrixInstanceConfig{
ActivityIndicator: nil, // enabled
},
BaseChannel: channels.NewBaseChannel("test", bus.New(), nil),
}
ch.client = newStubClient("test.bitrix24.com", rt)
ch.botID = 456
err := ch.OnActivityEvent(context.Background(), "chat789", "IMBOT_AGENT_ACTION_SEARCHING")
if err != nil {
t.Errorf("OnActivityEvent should return nil, got %v", err)
}
if len(rt.reqs) != 1 {
t.Fatalf("expected 1 call, got %d", len(rt.reqs))
}
// Verify params
params := rt.reqs[0]
if params.Get("botId") != "456" {
t.Errorf("botId param = %q, want 456", params.Get("botId"))
}
if params.Get("dialogId") != "chat789" {
t.Errorf("dialogId param = %q, want chat789", params.Get("dialogId"))
}
if params.Get("statusMessageCode") != "IMBOT_AGENT_ACTION_SEARCHING" {
t.Errorf("statusMessageCode param = %q, want IMBOT_AGENT_ACTION_SEARCHING", params.Get("statusMessageCode"))
}
if params.Get("duration") != "30" {
t.Errorf("duration param = %q, want 30", params.Get("duration"))
}
}
// TestOnActivityEvent_EmptyStatusCodeOmitsParam tests that empty status code omits the statusMessageCode param.
func TestOnActivityEvent_EmptyStatusCodeOmitsParam(t *testing.T) {
rt := &captureRT{}
ch := &Channel{
cfg: bitrixInstanceConfig{
ActivityIndicator: nil, // enabled
},
BaseChannel: channels.NewBaseChannel("test", bus.New(), nil),
}
ch.client = newStubClient("test.bitrix24.com", rt)
ch.botID = 789
err := ch.OnActivityEvent(context.Background(), "chat999", "")
if err != nil {
t.Errorf("OnActivityEvent with empty status should return nil, got %v", err)
}
if len(rt.reqs) != 1 {
t.Fatalf("expected 1 call, got %d", len(rt.reqs))
}
// Verify statusMessageCode is absent when empty
params := rt.reqs[0]
if params.Get("statusMessageCode") != "" {
t.Errorf("empty status should omit statusMessageCode, but got %q", params.Get("statusMessageCode"))
}
// Other params should still be present
if params.Get("botId") != "789" {
t.Errorf("botId should be present, got %q", params.Get("botId"))
}
}
// TestCompileTimeGuard verifies that *Channel satisfies ActivityIndicatorChannel.
func TestCompileTimeGuard(t *testing.T) {
// This is a compile-time check, but we verify the interface at runtime here.
var _ channels.ActivityIndicatorChannel = (*Channel)(nil)
t.Log("*Channel implements ActivityIndicatorChannel interface")
}