Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions pkg/dispatch/service/dispatch_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ import (
"github.com/EvolutionAPI/evo-bot-runtime/pkg/pipeline/model"
)

// DispatchEngine segments the AI response, appends the message signature,
// and sends each part sequentially via HTTP postback.
// DispatchEngine segments the AI response and sends each part sequentially
// via HTTP postback, unmodified — it does not apply BotConfig.MessageSignature
// (the CRM applies the agent-bot display-name prefix centrally; see
// dispatch_engine_test.go for the contract this must not regress).
// Swap the dispatch backend by providing a different implementation at main.go wiring.
type DispatchEngine interface {
Dispatch(
Expand Down Expand Up @@ -81,10 +83,10 @@ func (d *dispatchEngineImpl) Dispatch(

parts := segmentContent(residual, cfg)

// Prepend signature to the first part (FR-21)
if cfg.MessageSignature != "" && len(parts) > 0 {
parts[0] = cfg.MessageSignature + parts[0]
}
// FR-21's agent-bot display-name prefix is no longer applied here: the CRM
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
// now applies it once, centrally, for every outgoing AgentBot message
// (including this postback), so doing it here too would double-prefix it.
// cfg.MessageSignature is kept on BotConfig for callers that still read it.

start := time.Now()

Expand Down
59 changes: 19 additions & 40 deletions pkg/dispatch/service/dispatch_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
Expand Down Expand Up @@ -39,7 +38,13 @@ func collectParts(t *testing.T) (*httptest.Server, *[]string, *sync.Mutex) {
return server, &parts, &mu
}

func TestDispatch_MultiPart_SignatureOnFirstOnly(t *testing.T) {
// MessageSignature is no longer applied here: the CRM now prefixes the
// agent-bot display name once, centrally, for every outgoing AgentBot
// message (see Message#apply_agent_bot_signature on the CRM side) — doing
// it here too would double-prefix it. BotConfig still carries the field
// for callers that read it for other purposes, but Dispatch must leave
// content untouched regardless of its value.
func TestDispatch_MultiPart_SignatureNotAppliedByBotRuntime(t *testing.T) {
server, partsPtr, mu := collectParts(t)

eng := service.NewDispatchEngine("")
Expand All @@ -61,17 +66,17 @@ func TestDispatch_MultiPart_SignatureOnFirstOnly(t *testing.T) {
copy(parts, *partsPtr)
mu.Unlock()

if len(parts) <= 1 {
t.Fatalf("segmentation must produce multiple parts, got %d", len(parts))
// "hello world this is test" (limit 15) → ["hello world", "this is test"]
// (greedy word-packing; see segmentContent). Asserting the full parts,
// not just the signature's absence, so a future regression that drops or
// mangles segmented content can't hide behind a passing "no [bot]" check.
want := []string{"hello world", "this is test"}
if len(parts) != len(want) {
t.Fatalf("segmentation must produce %d parts, got %d: %q", len(want), len(parts), parts)
}

// Signature only on first part
if !strings.HasPrefix(parts[0], "[bot] ") {
t.Errorf("signature must be prepended to first part: %q", parts[0])
}
for i, p := range parts[1:] {
if strings.Contains(p, "[bot]") {
t.Errorf("signature must NOT be on part %d: %q", i+1, p)
for i, p := range parts {
if p != want[i] {
t.Errorf("part %d = %q, want %q (MessageSignature must not be applied by bot_runtime)", i, p, want[i])
}
}
}
Expand All @@ -98,9 +103,9 @@ func TestDispatch_NoSegmentation_SinglePart(t *testing.T) {
if len(parts) != 1 {
t.Fatalf("disabled segmentation must produce exactly one part, got %d", len(parts))
}
want := "—signature full response here"
want := "full response here"
if parts[0] != want {
t.Errorf("parts[0] = %q, want %q", parts[0], want)
t.Errorf("parts[0] = %q, want %q (MessageSignature must not be applied by bot_runtime)", parts[0], want)
}
}

Expand Down Expand Up @@ -146,32 +151,6 @@ func TestDispatch_Cancellation_ReturnsInterrupted(t *testing.T) {
}
}

func TestDispatch_EmptySignature_NoSuffix(t *testing.T) {
server, partsPtr, mu := collectParts(t)

eng := service.NewDispatchEngine("")
cfg := model.BotConfig{
TextSegmentationEnabled: false,
MessageSignature: "", // empty — no suffix
}

if err := eng.Dispatch(context.Background(), 4, 4, "no signature here", cfg, server.URL); err != nil {
t.Fatalf("Dispatch returned unexpected error: %v", err)
}

mu.Lock()
parts := make([]string, len(*partsPtr))
copy(parts, *partsPtr)
mu.Unlock()

if len(parts) != 1 {
t.Fatalf("expected 1 part, got %d", len(parts))
}
if parts[0] != "no signature here" {
t.Errorf("parts[0] = %q, want %q (empty signature must not append anything)", parts[0], "no signature here")
}
}

func TestDispatch_NonOKResponse_ReturnsError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
Expand Down
13 changes: 7 additions & 6 deletions test/e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -612,16 +612,18 @@ func TestE2E_PipelineIsolation(t *testing.T) {

// TestE2E_DispatchSegmentation verifies that BotConfig segmentation fields are
// threaded correctly from the event through the pipeline to the dispatch engine,
// and that the AI response is split into the expected parts with the signature
// appended only to the last one.
// and that the AI response is split into the expected parts. MessageSignature
// is set on the event to prove bot_runtime does NOT act on it — the CRM applies
// the agent-bot display-name prefix once, centrally, on every postback it
// receives (see Message#apply_agent_bot_signature on the CRM side), so
// bot_runtime must leave segment content untouched regardless of this field.
//
// dispatch_engine_test.go covers segmentation in isolation. This is the first
// test to exercise the full chain: BotConfig in the HTTP event → pipelineEntry →
// runDispatchStage → segmentContent → sequential postback POSTs.
//
// Content: "hello world foo bar", limit=10 chars per segment.
// segmentContent produces ["hello", "world foo", "bar"].
// With signature " [sig]" appended to the last part: "bar [sig]".
func TestE2E_DispatchSegmentation(t *testing.T) {
h := newHarness(t)
contactID, convID := nextPair()
Expand Down Expand Up @@ -650,12 +652,11 @@ func TestE2E_DispatchSegmentation(t *testing.T) {
t.Fatalf("postback called %d times, want 3 (one per segment)", n)
}

// EVO-558: the signature is prepended to the first segment only.
want := []string{" [sig]hello", "world foo", "bar"}
want := []string{"hello", "world foo", "bar"}
for i, body := range h.pbServer.allBodies() {
got := decodePostbackContent(body)
if got != want[i] {
t.Errorf("segment[%d] = %q, want %q", i, got, want[i])
t.Errorf("segment[%d] = %q, want %q (MessageSignature must not be applied by bot_runtime)", i, got, want[i])
}
}
}
Expand Down