answer customer messages sent while the AI is replying

This commit is contained in:
Abhinav Raut
2026-10-25 00:50:43 +05:30
parent dab18d4a9b
commit bb0d20c23a
2 changed files with 63 additions and 4 deletions
+31 -4
View File
@@ -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
}
+32
View File
@@ -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)
}
})
}
}