From 343c0ec18bee18d5e17c67fc7908cbc61cb77569 Mon Sep 17 00:00:00 2001 From: Mateus Bellozupko Date: Fri, 25 Sep 2026 05:43:53 -0300 Subject: [PATCH 1/2] fix(dispatch): stop prepending the agent-bot signature (double-apply) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dispatch_engine.go prepended cfg.MessageSignature to the first message part with no separator, so a signature like "Atendente" glued straight onto the reply text ("AtendentePronto, Mateus!..."). The CRM now applies this prefix itself, once, for every outgoing AgentBot message regardless of which path created it (Message#apply_agent_bot_signature in evo-ai-crm-community) — including this dispatcher's postback. Prepending it here too would double it. BotConfig.MessageSignature is left on the struct for callers that still read it; Dispatch itself no longer acts on it. Co-Authored-By: Claude Sonnet 5 --- pkg/dispatch/service/dispatch_engine.go | 8 ++-- pkg/dispatch/service/dispatch_engine_test.go | 46 +++++--------------- test/e2e/e2e_test.go | 13 +++--- 3 files changed, 22 insertions(+), 45 deletions(-) diff --git a/pkg/dispatch/service/dispatch_engine.go b/pkg/dispatch/service/dispatch_engine.go index 125f212..b8ddeae 100644 --- a/pkg/dispatch/service/dispatch_engine.go +++ b/pkg/dispatch/service/dispatch_engine.go @@ -81,10 +81,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 + // 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() diff --git a/pkg/dispatch/service/dispatch_engine_test.go b/pkg/dispatch/service/dispatch_engine_test.go index 08f799f..b4dd302 100644 --- a/pkg/dispatch/service/dispatch_engine_test.go +++ b/pkg/dispatch/service/dispatch_engine_test.go @@ -39,7 +39,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("") @@ -65,13 +71,9 @@ func TestDispatch_MultiPart_SignatureOnFirstOnly(t *testing.T) { t.Fatalf("segmentation must produce multiple parts, got %d", len(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:] { + for i, p := range parts { if strings.Contains(p, "[bot]") { - t.Errorf("signature must NOT be on part %d: %q", i+1, p) + t.Errorf("MessageSignature must not be applied by bot_runtime, found it on part %d: %q", i, p) } } } @@ -98,9 +100,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) } } @@ -146,32 +148,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) diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 0fb2e1c..cdbb904 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -612,8 +612,11 @@ 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 → @@ -621,7 +624,6 @@ func TestE2E_PipelineIsolation(t *testing.T) { // // 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() @@ -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]) } } } From 8d2ec9611b8a8d7473db949377b75d436aa9f798 Mon Sep 17 00:00:00 2001 From: Mateus Bellozupko Date: Fri, 25 Sep 2026 09:11:10 -0300 Subject: [PATCH 2/2] fix(dispatch): update interface doc, assert full segmented content in test - DispatchEngine's interface comment still said it appends the message signature; it deliberately doesn't since the CRM prefix centralization. - The multipart test only checked "[bot]" was absent, which would still pass if a regression dropped or mangled segment content as long as that literal string was missing. Assert the exact expected parts. Co-Authored-By: Claude Sonnet 5 --- pkg/dispatch/service/dispatch_engine.go | 6 ++++-- pkg/dispatch/service/dispatch_engine_test.go | 15 +++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/pkg/dispatch/service/dispatch_engine.go b/pkg/dispatch/service/dispatch_engine.go index b8ddeae..eb3a8bc 100644 --- a/pkg/dispatch/service/dispatch_engine.go +++ b/pkg/dispatch/service/dispatch_engine.go @@ -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( diff --git a/pkg/dispatch/service/dispatch_engine_test.go b/pkg/dispatch/service/dispatch_engine_test.go index b4dd302..e16ceaf 100644 --- a/pkg/dispatch/service/dispatch_engine_test.go +++ b/pkg/dispatch/service/dispatch_engine_test.go @@ -6,7 +6,6 @@ import ( "errors" "net/http" "net/http/httptest" - "strings" "sync" "testing" "time" @@ -67,13 +66,17 @@ func TestDispatch_MultiPart_SignatureNotAppliedByBotRuntime(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) } - for i, p := range parts { - if strings.Contains(p, "[bot]") { - t.Errorf("MessageSignature must not be applied by bot_runtime, found it on part %d: %q", i, p) + if p != want[i] { + t.Errorf("part %d = %q, want %q (MessageSignature must not be applied by bot_runtime)", i, p, want[i]) } } }