Skip to content

Commit dfe6b2b

Browse files
committed
fix(a2a): usar UUID da conversa como contextId (restaura memória do agente)
O ai_adapter enviava o display_id numérico da conversa como contextId no JSON-RPC para o AI Processor. O Processor monta a chave de sessão do ADK como "{contextId}_{agentID}", então com o display_id ("3") a chave nunca casava com a sessão persistida (que usa o UUID), resultando em "404 Session not found" e 0 mensagens de histórico em toda mensagem — o agente perdia a memória da conversa e respondia cada turno do zero (fluxo stateful quebrado, etiquetas reaplicadas, respostas fora de ordem). Correção: extrair o UUID que o CRM aninha em metadata.evoai_crm_data.conversation.id e usá-lo como contextId, com fallback para o ID numérico quando o metadata estiver ausente (callers legados). Testes: pkg/ai/service passa, incluindo cobertura do UUID e do fallback. Nota: o test file estava órfão de um refactor anterior de NewAIAdapter (assinatura antiga) — foi realinhado aqui. `go test ./...` segue vermelho no repo por outros test files órfãos do mesmo refactor (dispatch_engine_test.go, e2e_test.go), pré-existentes a esta mudança; CI publica imagem e não roda go test.
1 parent 12e75c8 commit dfe6b2b

2 files changed

Lines changed: 105 additions & 17 deletions

File tree

pkg/ai/service/ai_adapter.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,21 @@ func (a *aiAdapter) Call(ctx context.Context, req *model.A2ARequest) (*model.Nor
4848
// Use the full outgoing_url provided by the CRM (already contains the agent ID)
4949
url := req.OutgoingURL
5050

51-
// Build JSON-RPC 2.0 envelope
51+
// Build JSON-RPC 2.0 envelope.
52+
// contextId MUST be the conversation UUID, not the numeric display_id: the AI
53+
// Processor builds the ADK session key as "{contextId}_{agentID}". Using the
54+
// display_id ("3") collides across accounts and never matches the session the
55+
// Processor persisted (which keys on the UUID), so every turn reads 0 history
56+
// and the agent loses memory. Prefer the UUID from the CRM metadata; fall back
57+
// to the numeric ID only when the metadata is absent (e.g. legacy callers).
58+
contextID := conversationContextID(req.Metadata, req.ConversationID)
59+
5260
rpcReq := model.JSONRPCRequest{
5361
JSONRPC: "2.0",
5462
ID: fmt.Sprintf("%d:%d", req.ContactID, req.ConversationID),
5563
Method: "message/send",
5664
Params: model.JSONRPCParams{
57-
ContextID: fmt.Sprintf("%d", req.ConversationID),
65+
ContextID: contextID,
5866
UserID: fmt.Sprintf("%d", req.ContactID),
5967
Message: model.JSONRPCMessage{
6068
Role: "user",
@@ -149,3 +157,33 @@ func nonNilMetadata(m map[string]any) map[string]any {
149157
}
150158
return m
151159
}
160+
161+
// conversationContextID resolves the contextId for the JSON-RPC call. It reads the
162+
// conversation UUID the CRM nests at metadata.evoai_crm_data.conversation.id and
163+
// returns it; if any hop is missing or empty it falls back to the numeric
164+
// conversation ID (legacy behaviour) so callers without metadata still work.
165+
func conversationContextID(metadata map[string]any, conversationID int64) string {
166+
fallback := fmt.Sprintf("%d", conversationID)
167+
if uuid := extractConversationUUID(metadata); uuid != "" {
168+
return uuid
169+
}
170+
return fallback
171+
}
172+
173+
// extractConversationUUID digs metadata.evoai_crm_data.conversation.id out of the
174+
// untyped CRM metadata map. Returns "" when the path is absent or not a string.
175+
func extractConversationUUID(metadata map[string]any) string {
176+
crmData, ok := metadata["evoai_crm_data"].(map[string]any)
177+
if !ok {
178+
return ""
179+
}
180+
conversation, ok := crmData["conversation"].(map[string]any)
181+
if !ok {
182+
return ""
183+
}
184+
id, ok := conversation["id"].(string)
185+
if !ok {
186+
return ""
187+
}
188+
return id
189+
}

pkg/ai/service/ai_adapter_test.go

Lines changed: 65 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ import (
1515
aiService "github.com/EvolutionAPI/evo-bot-runtime/pkg/ai/service"
1616
)
1717

18+
// crmMetadata builds the nested CRM metadata map the Rails AgentBotListener sends,
19+
// carrying the conversation UUID at evoai_crm_data.conversation.id.
20+
func crmMetadata(conversationUUID string) map[string]any {
21+
return map[string]any{
22+
"evoai_crm_data": map[string]any{
23+
"conversation": map[string]any{
24+
"id": conversationUUID,
25+
"display_id": 7,
26+
},
27+
},
28+
}
29+
}
30+
1831
func TestCall_Success(t *testing.T) {
1932
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2033
// Verify X-API-Key header (per-event auth)
@@ -25,7 +38,7 @@ func TestCall_Success(t *testing.T) {
2538
t.Errorf("Content-Type = %q, want application/json", got)
2639
}
2740

28-
// Verify URL path contains agent_bot_id
41+
// Verify URL path is the OutgoingURL provided by the CRM (already contains the agent ID)
2942
if !strings.HasSuffix(r.URL.Path, "/api/v1/a2a/agent-123") {
3043
t.Errorf("URL path = %q, want suffix /api/v1/a2a/agent-123", r.URL.Path)
3144
}
@@ -41,8 +54,9 @@ func TestCall_Success(t *testing.T) {
4154
if req.Method != "message/send" {
4255
t.Errorf("req.Method = %q, want %q", req.Method, "message/send")
4356
}
44-
if req.Params.ContextID != "7" {
45-
t.Errorf("req.Params.ContextID = %q, want %q", req.Params.ContextID, "7")
57+
// contextId must be the conversation UUID from metadata, not the numeric ID.
58+
if req.Params.ContextID != "conv-uuid-abc" {
59+
t.Errorf("req.Params.ContextID = %q, want %q", req.Params.ContextID, "conv-uuid-abc")
4660
}
4761
if req.Params.UserID != "42" {
4862
t.Errorf("req.Params.UserID = %q, want %q", req.Params.UserID, "42")
@@ -65,13 +79,14 @@ func TestCall_Success(t *testing.T) {
6579
}))
6680
defer server.Close()
6781

68-
adapter := aiService.NewAIAdapter(server.URL, 30)
82+
adapter := aiService.NewAIAdapter(30)
6983
resp, err := adapter.Call(context.Background(), &aiModel.A2ARequest{
70-
AgentBotID: "agent-123",
84+
OutgoingURL: server.URL + "/api/v1/a2a/agent-123",
7185
Message: "hello world",
7286
ContactID: 42,
7387
ConversationID: 7,
7488
ApiKey: "test-key",
89+
Metadata: crmMetadata("conv-uuid-abc"),
7590
})
7691
if err != nil {
7792
t.Fatalf("Call returned unexpected error: %v", err)
@@ -81,6 +96,41 @@ func TestCall_Success(t *testing.T) {
8196
}
8297
}
8398

99+
// TestCall_ContextID_FallsBackToNumericID asserts that when the CRM metadata is
100+
// absent the adapter falls back to the numeric conversation ID (legacy behaviour),
101+
// so callers that do not send metadata keep working.
102+
func TestCall_ContextID_FallsBackToNumericID(t *testing.T) {
103+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
104+
var req aiModel.JSONRPCRequest
105+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
106+
t.Errorf("decode request body: %v", err)
107+
}
108+
if req.Params.ContextID != "7" {
109+
t.Errorf("req.Params.ContextID = %q, want fallback %q", req.Params.ContextID, "7")
110+
}
111+
json.NewEncoder(w).Encode(aiModel.A2AResponse{
112+
Result: &aiModel.A2AResult{
113+
Artifacts: []aiModel.A2AArtifact{
114+
{Parts: []aiModel.A2APart{{Type: "text", Text: "ok"}}},
115+
},
116+
},
117+
})
118+
}))
119+
defer server.Close()
120+
121+
adapter := aiService.NewAIAdapter(30)
122+
_, err := adapter.Call(context.Background(), &aiModel.A2ARequest{
123+
OutgoingURL: server.URL,
124+
Message: "test",
125+
ConversationID: 7,
126+
ApiKey: "key",
127+
// No Metadata → must fall back to the numeric ConversationID.
128+
})
129+
if err != nil {
130+
t.Fatalf("Call returned unexpected error: %v", err)
131+
}
132+
}
133+
84134
func TestCall_Success_MessageFormat(t *testing.T) {
85135
// Test the fallback response format (result.message instead of result.artifacts)
86136
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -95,11 +145,11 @@ func TestCall_Success_MessageFormat(t *testing.T) {
95145
}))
96146
defer server.Close()
97147

98-
adapter := aiService.NewAIAdapter(server.URL, 30)
148+
adapter := aiService.NewAIAdapter(30)
99149
resp, err := adapter.Call(context.Background(), &aiModel.A2ARequest{
100-
AgentBotID: "bot-1",
101-
Message: "test",
102-
ApiKey: "key",
150+
OutgoingURL: server.URL,
151+
Message: "test",
152+
ApiKey: "key",
103153
})
104154
if err != nil {
105155
t.Fatalf("Call returned unexpected error: %v", err)
@@ -122,15 +172,15 @@ func TestCall_ContextCancellation_ReturnsPipelineCancelled(t *testing.T) {
122172
server.Close()
123173
})
124174

125-
adapter := aiService.NewAIAdapter(server.URL, 30)
175+
adapter := aiService.NewAIAdapter(30)
126176
ctx, cancel := context.WithCancel(context.Background())
127177

128178
go func() {
129179
time.Sleep(50 * time.Millisecond)
130180
cancel()
131181
}()
132182

133-
_, err := adapter.Call(ctx, &aiModel.A2ARequest{AgentBotID: "bot-1", Message: "test", ApiKey: "key"})
183+
_, err := adapter.Call(ctx, &aiModel.A2ARequest{OutgoingURL: server.URL, Message: "test", ApiKey: "key"})
134184
if !errors.Is(err, brtErrors.ErrPipelineCancelled) {
135185
t.Errorf("expected ErrPipelineCancelled, got %v", err)
136186
}
@@ -149,8 +199,8 @@ func TestCall_Timeout_ReturnsAITimeout(t *testing.T) {
149199
server.Close()
150200
})
151201

152-
adapter := aiService.NewAIAdapter(server.URL, 1) // 1 s timeout
153-
_, err := adapter.Call(context.Background(), &aiModel.A2ARequest{AgentBotID: "bot-1", Message: "test", ApiKey: "key"})
202+
adapter := aiService.NewAIAdapter(1) // 1 s timeout
203+
_, err := adapter.Call(context.Background(), &aiModel.A2ARequest{OutgoingURL: server.URL, Message: "test", ApiKey: "key"})
154204
if !errors.Is(err, brtErrors.ErrAITimeout) {
155205
t.Errorf("expected ErrAITimeout, got %v", err)
156206
}
@@ -162,8 +212,8 @@ func TestCall_NonOKStatus_ReturnsError(t *testing.T) {
162212
}))
163213
defer server.Close()
164214

165-
adapter := aiService.NewAIAdapter(server.URL, 30)
166-
_, err := adapter.Call(context.Background(), &aiModel.A2ARequest{AgentBotID: "bot-1", Message: "test", ApiKey: "key"})
215+
adapter := aiService.NewAIAdapter(30)
216+
_, err := adapter.Call(context.Background(), &aiModel.A2ARequest{OutgoingURL: server.URL, Message: "test", ApiKey: "key"})
167217
if err == nil {
168218
t.Fatal("expected error for non-200 response, got nil")
169219
}

0 commit comments

Comments
 (0)