diff --git a/pkg/services/agent_message_test.go b/pkg/services/agent_message_test.go new file mode 100644 index 0000000..37954ee --- /dev/null +++ b/pkg/services/agent_message_test.go @@ -0,0 +1,42 @@ +package services + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestAgentMessageRawRoundTrip confirma que un campo que el proveedor manda +// y que nuestra struct no conoce (ej. thought_signature de Gemini) sobrevive +// a un ciclo unmarshal->marshal en vez de perderse — es justo lo que hacía +// que Gemini rechazara la segunda ronda de una tool call. +func TestAgentMessageRawRoundTrip(t *testing.T) { + original := `{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"buscar_conocimiento","arguments":"{}"},"thought_signature":"opaco-123"}]}` + + var msg agentMessage + if err := json.Unmarshal([]byte(original), &msg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(msg.ToolCalls) != 1 || msg.ToolCalls[0].Function.Name != "buscar_conocimiento" { + t.Fatalf("no se poblaron los campos tipados normalmente: %+v", msg) + } + + out, err := json.Marshal(msg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(out), `"thought_signature":"opaco-123"`) { + t.Errorf("se perdió el campo desconocido al reserializar: %s", out) + } + + // Un mensaje armado por nosotros (no parseado) no debe llevar Raw ni + // romperse por eso — tiene que serializar normal. + propio := agentMessage{Role: "user", Content: "hola"} + outPropio, err := json.Marshal(propio) + if err != nil { + t.Fatalf("marshal de mensaje propio: %v", err) + } + if !strings.Contains(string(outPropio), `"role":"user"`) || !strings.Contains(string(outPropio), `"content":"hola"`) { + t.Errorf("mensaje propio no serializó bien: %s", outPropio) + } +} diff --git a/pkg/services/telegram_agent_service.go b/pkg/services/telegram_agent_service.go index 64a0f9b..fd3c7d0 100644 --- a/pkg/services/telegram_agent_service.go +++ b/pkg/services/telegram_agent_service.go @@ -47,6 +47,38 @@ type agentMessage struct { // Adjunto opcional para que el modelo lea el archivo real (solo Anthropic). AttachmentBase64 string `json:"-"` AttachmentMime string `json:"-"` + // Raw guarda el JSON tal cual vino del proveedor cuando este mensaje se + // parseó de una respuesta (no cuando lo armamos nosotros). Algunos + // proveedores mandan campos propios fuera de nuestra struct — ej. Gemini + // exige que el tool_call de un modelo "thinking" se reenvíe con su + // thought_signature intacto, si no rechaza la siguiente ronda con 400. + // Reenviar los bytes originales en vez de reserializar desde los campos + // tipados preserva cualquier campo así sin que tengamos que conocerlo. + Raw json.RawMessage `json:"-"` +} + +// UnmarshalJSON guarda una copia de los bytes originales en Raw además de +// poblar los campos normales — así un mensaje que vino de la respuesta del +// proveedor se puede reenviar exactamente como llegó. +func (m *agentMessage) UnmarshalJSON(data []byte) error { + type alias agentMessage + var a alias + if err := json.Unmarshal(data, &a); err != nil { + return err + } + *m = agentMessage(a) + m.Raw = append(json.RawMessage(nil), data...) + return nil +} + +// MarshalJSON reenvía Raw tal cual si el mensaje vino de un unmarshal +// previo; si lo armamos nosotros (Raw vacío), serializa los campos normales. +func (m agentMessage) MarshalJSON() ([]byte, error) { + if len(m.Raw) > 0 { + return m.Raw, nil + } + type alias agentMessage + return json.Marshal(alias(m)) } type agentToolCall struct {