From bb0d20c23a06fa8ca5023df6a5e124b71ddfdfab Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sun, 25 Oct 2026 00:50:43 +0530 Subject: [PATCH] answer customer messages sent while the AI is replying --- internal/aiagent/worker.go | 35 +++++++++++++++++++++++++++++---- internal/aiagent/worker_test.go | 32 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/internal/aiagent/worker.go b/internal/aiagent/worker.go index 7f6b62a8..3895f37a 100644 --- a/internal/aiagent/worker.go +++ b/internal/aiagent/worker.go @@ -234,14 +234,19 @@ func (m *Manager) handle(ctx context.Context, convID int) { if inbound == nil { return } - // Only respond to a fresh inbound customer message, never to the assistant's own or automated - // replies. A requeued run sees the previous run's just-posted reply as the last message, so it - // instead checks for an inbound message the previous run's history never included. m.mu.Lock() prevSeen, hasPrev := m.lastSeen[convID] m.lastSeen[convID] = msgs[len(msgs)-1].ID m.mu.Unlock() - if !lastIsInboundContact(msgs) && (!hasPrev || inbound.ID <= prevSeen) { + // A requeued run finds the previous run's reply after the customer messages that arrived while it + // was being written. Moving the reply above them keeps the history in the order the assistant saw + // it and leaves the newest customer message last, which providers require. + if hasPrev { + msgs = previousReplyFirst(msgs, prevSeen) + } + // Only respond when the newest message is the customer's, never to the assistant's own, a human's + // or automated replies. + if !lastIsInboundContact(msgs) { return } // The reply and any identity-scoped tools act as the primary contact. An email thread can carry @@ -867,3 +872,25 @@ func messageMetaBool(raw json.RawMessage, key string) bool { value, _ := meta[key].(bool) return value } + +// previousReplyFirst moves AI replies posted after prevSeen above the other messages that arrived since. +func previousReplyFirst(msgs []cmodels.Message, prevSeen int) []cmodels.Message { + out := make([]cmodels.Message, 0, len(msgs)) + var later []cmodels.Message + for _, msg := range msgs { + if msg.ID > prevSeen && !isAIReply(msg) { + later = append(later, msg) + continue + } + out = append(out, msg) + } + return append(out, later...) +} + +// isAIReply matches replies from any assistant, so a reassignment mid-run still finds the old reply. +func isAIReply(msg cmodels.Message) bool { + var meta struct { + AssistantID int `json:"ai_assistant_id"` + } + return json.Unmarshal(msg.Meta, &meta) == nil && meta.AssistantID > 0 +} diff --git a/internal/aiagent/worker_test.go b/internal/aiagent/worker_test.go index 318d194b..d67dc7af 100644 --- a/internal/aiagent/worker_test.go +++ b/internal/aiagent/worker_test.go @@ -1,12 +1,14 @@ package aiagent import ( + "encoding/json" "reflect" "strings" "testing" aimodels "github.com/abhinavxd/libredesk/internal/ai/models" "github.com/abhinavxd/libredesk/internal/aiagent/models" + cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" "github.com/abhinavxd/libredesk/internal/stringutil" ) @@ -109,3 +111,33 @@ func TestArticleCitationHTML(t *testing.T) { t.Fatalf("unexpected preview: %q", got) } } + +func TestPreviousReplyFirst(t *testing.T) { + msg := func(id int) cmodels.Message { return cmodels.Message{ID: id} } + reply := func(id int) cmodels.Message { + return cmodels.Message{ID: id, Meta: json.RawMessage(`{"ai_assistant_id":7}`)} + } + tests := []struct { + name string + msgs []cmodels.Message + prevSeen int + want []int + }{ + {"messages sent while replying", []cmodels.Message{msg(1), msg(2), msg(3), reply(4)}, 1, []int{1, 4, 2, 3}}, + {"split reply", []cmodels.Message{msg(1), msg(2), reply(3), reply(4)}, 1, []int{1, 3, 4, 2}}, + {"no new message", []cmodels.Message{msg(1), reply(2)}, 1, []int{1, 2}}, + {"no reply posted", []cmodels.Message{msg(1), msg(2)}, 1, []int{1, 2}}, + {"older replies untouched", []cmodels.Message{msg(1), reply(2), msg(3), msg(4), reply(5)}, 3, []int{1, 2, 3, 5, 4}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got []int + for _, m := range previousReplyFirst(tt.msgs, tt.prevSeen) { + got = append(got, m.ID) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + }) + } +}