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
15 changes: 12 additions & 3 deletions pkg/ai/model/a2a.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,21 @@ type A2AMessage struct {
}

type A2APart struct {
Type string `json:"type"`
Text string `json:"text"`
Type string `json:"type"`
Text string `json:"text,omitempty"`
Items []SelectItem `json:"items,omitempty"`
}

// SelectItem represents a single selectable option from a Typebot choice block.
type SelectItem struct {
Title string `json:"title"`
Value string `json:"value"`
}

// NormalizedResponse is the internal format after parsing A2AResponse.
// No JSON tags — this type never crosses a service boundary.
type NormalizedResponse struct {
Content string
Content string
ContentType string // "text" (default) or "input_select"
Items []SelectItem // populated when ContentType == "input_select"
}
49 changes: 32 additions & 17 deletions pkg/ai/service/ai_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,42 +121,57 @@ func (a *aiAdapter) Call(ctx context.Context, req *model.A2ARequest) (*model.Nor
return nil, fmt.Errorf("pipeline.ai.decode: %w", err)
}

content := extractResponseText(&a2aResp)
nr := extractNormalized(&a2aResp)

slog.Info("pipeline.ai.http.completed",
"contact_id", req.ContactID,
"contact_id", req.ContactID,
"conversation_id", req.ConversationID,
"duration_ms", time.Since(start).Milliseconds(),
"content_type", nr.ContentType,
"has_items", len(nr.Items) > 0,
)

return &model.NormalizedResponse{Content: content}, nil
return nr, nil
}

// extractResponseText extracts the text content from the A2A JSON-RPC response.
// Tries result.artifacts[0].parts[0].text first, then result.message.parts[0].text.
func extractResponseText(resp *model.A2AResponse) string {
// extractNormalized parses an A2A JSON-RPC response into a NormalizedResponse.
// It scans all artifacts and parts, capturing:
// - the first non-empty "text" part as Content
// - the first "select" part (if present) as ContentType=="input_select" + Items
// Falls back to result.message.parts when no artifact text is found.
func extractNormalized(resp *model.A2AResponse) *model.NormalizedResponse {
nr := &model.NormalizedResponse{ContentType: "text"}
if resp.Result == nil {
return ""
return nr
}
// Try artifacts first (primary response format)
if len(resp.Result.Artifacts) > 0 {
for _, artifact := range resp.Result.Artifacts {
for _, part := range artifact.Parts {
if part.Text != "" {
return part.Text

// Scan all artifacts and all parts in each artifact.
for _, artifact := range resp.Result.Artifacts {
for _, part := range artifact.Parts {
switch part.Type {
case "text":
if nr.Content == "" && part.Text != "" {
nr.Content = part.Text
}
case "select":
if nr.ContentType == "text" && len(part.Items) > 0 {
nr.ContentType = "input_select"
nr.Items = part.Items
}
}
}
}
// Fallback to message format
if resp.Result.Message != nil {

// Fallback to message format when no artifact text found.
if nr.Content == "" && resp.Result.Message != nil {
for _, part := range resp.Result.Message.Parts {
if part.Text != "" {
return part.Text
nr.Content = part.Text
break
}
}
}
return ""
return nr
}

// nonNilMetadata ensures metadata is never nil (avoids "null" in JSON).
Expand Down
45 changes: 45 additions & 0 deletions pkg/ai/service/ai_adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,51 @@ func TestCall_Timeout_ReturnsAITimeout(t *testing.T) {
}
}

func TestCall_SelectResponse_PropagatesItems(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := aiModel.A2AResponse{
Result: &aiModel.A2AResult{
Artifacts: []aiModel.A2AArtifact{
{Parts: []aiModel.A2APart{
{Type: "text", Text: "Choose an option"},
{Type: "select", Items: []aiModel.SelectItem{
{Title: "Option A", Value: "a"},
{Title: "Option B", Value: "b"},
}},
}},
},
},
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()

adapter := aiService.NewAIAdapter(30)
resp, err := adapter.Call(context.Background(), &aiModel.A2ARequest{
OutgoingURL: server.URL,
Message: "test",
ApiKey: "key",
})
if err != nil {
t.Fatalf("Call returned unexpected error: %v", err)
}
if resp.ContentType != "input_select" {
t.Errorf("resp.ContentType = %q, want %q", resp.ContentType, "input_select")
}
if resp.Content != "Choose an option" {
t.Errorf("resp.Content = %q, want %q", resp.Content, "Choose an option")
}
if len(resp.Items) != 2 {
t.Fatalf("resp.Items length = %d, want 2", len(resp.Items))
}
if resp.Items[0].Title != "Option A" || resp.Items[0].Value != "a" {
t.Errorf("resp.Items[0] = %+v, want {Title: Option A, Value: a}", resp.Items[0])
}
if resp.Items[1].Title != "Option B" || resp.Items[1].Value != "b" {
t.Errorf("resp.Items[1] = %+v, want {Title: Option B, Value: b}", resp.Items[1])
}
}

func TestCall_NonOKStatus_ReturnsError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
Expand Down
63 changes: 46 additions & 17 deletions pkg/dispatch/service/dispatch_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"unicode/utf8"

brtErrors "github.com/EvolutionAPI/evo-bot-runtime/internal/errors"
aiModel "github.com/EvolutionAPI/evo-bot-runtime/pkg/ai/model"
"github.com/EvolutionAPI/evo-bot-runtime/pkg/pipeline/model"
)

Expand All @@ -25,7 +26,7 @@ type DispatchEngine interface {
ctx context.Context,
contactID int64,
conversationID int64,
content string,
resp *aiModel.NormalizedResponse,
cfg model.BotConfig,
postbackURL string,
) error
Expand All @@ -36,6 +37,7 @@ type postbackRequest struct {
Content string `json:"content"`
MessageType string `json:"message_type"`
ContentType string `json:"content_type"`
Items []aiModel.SelectItem `json:"items,omitempty"`
Attachments []postbackAttachment `json:"attachments,omitempty"`
}

Expand Down Expand Up @@ -69,17 +71,25 @@ func (d *dispatchEngineImpl) Dispatch(
ctx context.Context,
contactID int64,
conversationID int64,
content string,
resp *aiModel.NormalizedResponse,
cfg model.BotConfig,
postbackURL string,
) error {
// Pull media URLs out of the full response BEFORE segmenting, so media is
// not split across text parts. Media is delivered in a single dedicated
// postback after the text parts (see below). The CRM also re-detects media
// from the text as a fallback, so this is an optimization, not the only path.
residual, atts := extractMediaURLs(content)

parts := segmentContent(residual, cfg)
// Structured messages (input_select) must never be segmented — send as one part.
// Media extraction only applies to text responses.
var parts []string
var atts []postbackAttachment
if resp.ContentType == "input_select" {
parts = []string{resp.Content}
} else {
// Pull media URLs out of the full response BEFORE segmenting, so media is
// not split across text parts. Media is delivered in a single dedicated
// postback after the text parts (see below). The CRM also re-detects media
// from the text as a fallback, so this is an optimization, not the only path.
residual, extracted := extractMediaURLs(resp.Content)
atts = extracted
parts = segmentContent(residual, cfg)
}

// Prepend signature to the first part (FR-21)
if cfg.MessageSignature != "" && len(parts) > 0 {
Expand All @@ -90,8 +100,10 @@ func (d *dispatchEngineImpl) Dispatch(

for i, part := range parts {
// Skip empty residual (e.g. response was only a media URL): the media
// postback below still runs.
if part == "" {
// postback below still runs. input_select is exempt — its items must
// always be delivered even when the AI processor sends no accompanying
// text (e.g. a choice block with no question text).
if part == "" && resp.ContentType != "input_select" {
continue
}

Expand All @@ -107,7 +119,13 @@ func (d *dispatchEngineImpl) Dispatch(
default:
}

if err := d.sendPart(ctx, postbackURL, part, nil); err != nil {
// Only the last (or only) part carries structured items to avoid duplication.
var items []aiModel.SelectItem
if i == len(parts)-1 && resp.ContentType == "input_select" {
items = resp.Items
}

if err := d.sendPart(ctx, postbackURL, part, resp.ContentType, nil, items); err != nil {
return fmt.Errorf("pipeline.dispatch.send[%d]: %w", i, err)
}

Expand All @@ -134,7 +152,7 @@ func (d *dispatchEngineImpl) Dispatch(
return brtErrors.ErrDispatchInterrupted
default:
}
if err := d.sendPart(ctx, postbackURL, "", atts); err != nil {
if err := d.sendPart(ctx, postbackURL, "", "", atts, nil); err != nil {
return fmt.Errorf("pipeline.dispatch.send[media]: %w", err)
}
}
Expand All @@ -149,13 +167,24 @@ func (d *dispatchEngineImpl) Dispatch(
return nil
}

// sendPart sends a single content part (and optional media attachments) to the
// postback URL.
func (d *dispatchEngineImpl) sendPart(ctx context.Context, postbackURL, content string, atts []postbackAttachment) error {
// sendPart sends a single content part (and optional media attachments or
// structured items) to the postback URL.
func (d *dispatchEngineImpl) sendPart(
ctx context.Context,
postbackURL string,
content string,
contentType string,
atts []postbackAttachment,
items []aiModel.SelectItem,
) error {
if contentType == "" {
contentType = "text"
}
body, err := json.Marshal(postbackRequest{
Content: content,
MessageType: "outgoing",
ContentType: "text",
ContentType: contentType,
Items: items,
Attachments: atts,
})
if err != nil {
Expand Down
Loading